PillGroup
Introduction
MPillGroup is a pill-style option group component supporting both single and multiple selection. It accepts string arrays or object arrays as items, maps business object fields via labelKey, valueKey, and descriptionKey, and supports quantity constraints, per-item semantic colors, active/inactive style differentiation, and slot customization.
Usage
Pass a string array to generate single-select pills. The selected value is written directly to v-model:
<script setup lang="ts">
const value = ref("All")
</script>
<template>
<MPillGroup v-model="value" :items='["All","Todo","In Progress","Done","Archived"]' />
</template>
items Structured Items
Object items can render composite content via label, description, and icon, while preserving the original object as the value:
<script setup lang="ts">
const value = ref(null)
</script>
<template>
<MPillGroup
v-model="value"
:items="[
{ value: 'free', label: 'Free', description: 'Basic · 1 user', icon: 'i-lucide-gift' },
{ value: 'pro', label: 'Pro', description: 'Team · 10 users', icon: 'i-lucide-zap' },
{ value: 'team', label: 'Team', description: 'Insights · 50 users', icon: 'i-lucide-users' },
{
value: 'enterprise',
label: 'Enterprise',
description: 'Custom SLA · Unlimited',
icon: 'i-lucide-building'
}
]"
/>
</template>
multiple Multiple Selection Mode
When multiple is enabled, modelValue becomes an array. Use valueKey to save only a stable field as the selected value:
<script setup lang="ts">
const value = ref([])
</script>
<template>
<MPillGroup
v-model="value"
:items="[
{ value: 'free', label: 'Free', icon: 'i-lucide-gift' },
{ value: 'pro', label: 'Pro', icon: 'i-lucide-zap' },
{ value: 'team', label: 'Team', icon: 'i-lucide-users' },
{ value: 'enterprise', label: 'Enterprise', icon: 'i-lucide-building' }
]"
multiple
value-key="value"
/>
</template>
deselectable Single Deselect
deselectable allows clicking the current item again to clear the selection — useful for optional filter conditions (ignored in multiple mode):
<script setup lang="ts">
const value = ref(null)
</script>
<template>
<MPillGroup
v-model="value"
:items="[
{ value: 'free', label: 'Free', icon: 'i-lucide-gift' },
{ value: 'pro', label: 'Pro', icon: 'i-lucide-zap' },
{ value: 'team', label: 'Team', icon: 'i-lucide-users' },
{ value: 'enterprise', label: 'Enterprise', icon: 'i-lucide-building' }
]"
deselectable
/>
</template>
orientation Layout Direction
Setting orientation to vertical stacks items vertically — suited for longer descriptions or narrow containers:
<script setup lang="ts">
const value = ref(null)
</script>
<template>
<MPillGroup
v-model="value"
:items="[
{ value: 'free', label: 'Free', description: 'Basic · 1 user', icon: 'i-lucide-gift' },
{ value: 'pro', label: 'Pro', description: 'Team · 10 users', icon: 'i-lucide-zap' },
{ value: 'team', label: 'Team', description: 'Insights · 50 users', icon: 'i-lucide-users' },
{
value: 'enterprise',
label: 'Enterprise',
description: 'Custom SLA · Unlimited',
icon: 'i-lucide-building'
}
]"
orientation="vertical"
/>
</template>
activeVariant Active Style
activeVariant and inactiveVariant control the visual variant for selected and unselected states respectively:
<script setup lang="ts">
const value = ref(null)
</script>
<template>
<MPillGroup
v-model="value"
:items="[
{ value: 'free', label: 'Free', icon: 'i-lucide-gift' },
{ value: 'pro', label: 'Pro', icon: 'i-lucide-zap' },
{ value: 'team', label: 'Team', icon: 'i-lucide-users' },
{ value: 'enterprise', label: 'Enterprise', icon: 'i-lucide-building' }
]"
active-variant="solid"
inactive-variant="outline"
/>
</template>
size Size
Use size to switch pill sizes. When placed inside UFormField, the size is automatically inherited from context:
<script setup lang="ts">
const value = ref(null)
</script>
<template>
<MPillGroup
v-model="value"
:items="[
{ value: 'free', label: 'Free', icon: 'i-lucide-gift' },
{ value: 'pro', label: 'Pro', icon: 'i-lucide-zap' },
{ value: 'team', label: 'Team', icon: 'i-lucide-users' },
{ value: 'enterprise', label: 'Enterprise', icon: 'i-lucide-building' }
]"
size="md"
/>
</template>
color Primary Color
Use color to set the component's primary color, making it easier to coordinate with external UFieldGroup or button groups:
<script setup lang="ts">
const value = ref('All')
</script>
<template>
<MPillGroup
v-model="value"
:items="['All', 'Todo', 'In Progress', 'Done', 'Archived']"
color="primary"
/>
</template>
min and max Quantity Constraints
min and max constrain the number of multiple selections: unselected items become grayed out at the max, and selected items cannot be deselected at the min:
<script setup lang="ts">
const value = ref(['frontend', 'design'])
</script>
<template>
<MPillGroup
v-model="value"
:items="[
{ value: 'frontend', label: 'Frontend', icon: 'i-lucide-layout' },
{ value: 'backend', label: 'Backend', icon: 'i-lucide-server' },
{ value: 'devops', label: 'DevOps', icon: 'i-lucide-cloud' },
{ value: 'design', label: 'Design', icon: 'i-lucide-palette' },
{ value: 'data', label: 'Data', icon: 'i-lucide-database' }
]"
multiple
value-key="value"
:min="1"
:max="3"
/>
</template>
labelKey Display Field
labelKey specifies the field to read from the business object for display. Falls back to label if not set:
<script setup lang="ts">
const value = ref(null)
</script>
<template>
<MPillGroup
v-model="value"
:items="[
{ id: 'u1', name: 'Alice', handle: '@alice', email: 'alice@team.dev' },
{ id: 'u2', name: 'Bob', handle: '@bob', email: 'bob@team.dev' },
{ id: 'u3', name: 'Carol', handle: '@carol', email: 'carol@team.dev' }
]"
label-key="name"
/>
</template>
valueKey Value Field
valueKey specifies the field written back to modelValue, making it easy to save only a stable id rather than the entire object:
<script setup lang="ts">
const value = ref('u1')
</script>
<template>
<MPillGroup
v-model="value"
:items="[
{ id: 'u1', name: 'Alice', handle: '@alice', email: 'alice@team.dev' },
{ id: 'u2', name: 'Bob', handle: '@bob', email: 'bob@team.dev' },
{ id: 'u3', name: 'Carol', handle: '@carol', email: 'carol@team.dev' }
]"
label-key="name"
value-key="id"
/>
</template>
color Per-Item Semantic Color
Each item can carry an independent color, applied per item at render time to express status differences:
<script setup lang="ts">
const value = ref('doing')
</script>
<template>
<MPillGroup
v-model="value"
:items="[
{ value: 'todo', label: 'Todo', icon: 'i-lucide-circle', color: 'neutral' },
{ value: 'doing', label: 'In Progress', icon: 'i-lucide-loader', color: 'warning' },
{ value: 'done', label: 'Done', icon: 'i-lucide-check-circle', color: 'success' },
{ value: 'block', label: 'Blocked', icon: 'i-lucide-octagon-x', color: 'error' }
]"
value-key="value"
/>
</template>
disabled Disabled
A per-item disabled prevents interaction on that item. The component-level disabled freezes the entire group while preserving the current value:
<script setup lang="ts">
const value = ref('pdf')
</script>
<template>
<MPillGroup
v-model="value"
:items="[
{ value: 'pdf', label: 'PDF', icon: 'i-lucide-file-text' },
{
value: 'excel',
label: 'Excel (Coming Soon)',
icon: 'i-lucide-file-spreadsheet',
disabled: true
},
{ value: 'csv', label: 'CSV', icon: 'i-lucide-file' },
{ value: 'json', label: 'JSON', icon: 'i-lucide-braces' }
]"
value-key="value"
/>
</template>
Examples
Slot-Based Item Customization
The item-label, item-trailing, and other slots can take over partial rendering of individual items. The selection indicator updates with the selected slot prop:
<script setup lang="ts">
import type { PillItem } from '@movk/nuxt'
const items: PillItem[] = [
{ value: 'free', label: '免费版', icon: 'i-lucide-gift' },
{ value: 'pro', label: '专业版', icon: 'i-lucide-zap' },
{ value: 'enterprise', label: '企业版', icon: 'i-lucide-building' }
]
const value = ref<PillItem[]>([items[1]!])
function getItemLabel(item: PillItem): string {
if (typeof item === 'object' && item) return item.label ?? String(item.value)
return String(item)
}
</script>
<template>
<MPillGroup v-model="value" :items="items" multiple>
<template #item-label="{ item }">
<span class="font-semibold">{{ getItemLabel(item) }}</span>
</template>
<template #item-trailing="{ selected }">
<UIcon v-if="selected" name="i-lucide-check" class="size-3.5" />
</template>
</MPillGroup>
</template>
Event Callbacks
Clicking an item fires update:modelValue, change, and select in sequence. The select payload contains item, value, selected, and index:
(点击选项查看事件)
<script setup lang="ts">
import type { PillItem, PillSelectPayload } from '@movk/nuxt'
const items: PillItem[] = [
{ value: 'free', label: '免费版', icon: 'i-lucide-gift' },
{ value: 'pro', label: '专业版', icon: 'i-lucide-zap' },
{ value: 'enterprise', label: '企业版', icon: 'i-lucide-building' }
]
const value = ref<PillItem[]>([items[1]!])
const last = ref<string>('(点击选项查看事件)')
function onSelect(payload: PillSelectPayload<PillItem>) {
const label =
typeof payload.item === 'object' && payload.item
? (payload.item.label ?? String(payload.item.value))
: String(payload.item)
last.value = `select: ${label} → selected=${payload.selected}, index=${payload.index}`
}
</script>
<template>
<div class="flex flex-col gap-3">
<MPillGroup v-model="value" :items="items" multiple @select="onSelect" />
<p class="text-sm text-muted">
{{ last }}
</p>
</div>
</template>
API
Props
| Prop | Default | Type |
|---|---|---|
items | T[] | |
valueKey | VK | |
labelKey | 'label' | keyof Extract<NestedItem<T>, object> & string | DotPathKeys<Extract<NestedItem<T>, object>> |
descriptionKey | 'description' | keyof Extract<NestedItem<T>, object> & string | DotPathKeys<Extract<NestedItem<T>, object>> |
by | string | (a: T, b: T): boolean对象模式下判定 selected 的比较依据。string 走 | |
orientation | 'horizontal' | "horizontal" | "vertical"排列方向 |
size | 'md' | "xs" | "sm" | "md" | "lg" | "xl"按钮尺寸 |
color | 'primary' | "primary" | "secondary" | "info" | "success" | "warning" | "error" | "important" | "neutral"按钮颜色 |
activeVariant | 'solid' | "link" | "solid" | "outline" | "soft" | "subtle" | "ghost"选中态单项视觉变体 |
inactiveVariant | 'soft' | "link" | "solid" | "outline" | "soft" | "subtle" | "ghost"未选中态单项视觉变体 |
multiple | M是否可以多选 | |
max | number多选:最多可选数量 | |
min | number多选:最少需保留数量 | |
defaultValue | GetModelValue<T, VK, M, undefined> | |
modelValue | GetModelValue<T, VK, M, undefined> & GetModelValue<T, VK, M> | |
disabled | boolean | |
deselectable | false | boolean单选:再次点击当前选中项清空(多选模式下被忽略) |
ui | Record<string, ClassNameValue> & { root?: SlotClass; list?: SlotClass; item?: SlotClass; leading?: SlotClass; leadingIcon?: SlotClass; trailing?: SlotClass; trailingIcon?: SlotClass; itemWrapper?: SlotClass; itemLabel?: SlotClass; itemDescription?: SlotClass; } |
Emits
| Event | Type |
|---|---|
blur | [event: FocusEvent] |
focus | [event: FocusEvent] |
change | [value: GetModelValue<T, VK, M, undefined>] |
update:modelValue | [value: GetModelValue<T, VK, M, undefined>] |
select | [payload: PillSelectPayload<T>] |
Slots
| Slot | Type |
|---|---|
leading | { modelValue: GetModelValue<T, VK, M, undefined>; items: T[]; } |
default | { modelValue: GetModelValue<T, VK, M, undefined>; items: T[]; } |
trailing | { modelValue: GetModelValue<T, VK, M, undefined>; items: T[]; } |
item | { item: T; index: number; selected: boolean; } |
item-leading | { item: T; index: number; selected: boolean; } |
item-label | { item: T; index: number; selected: boolean; } |
item-description | { item: T; index: number; selected: boolean; } |
item-trailing | { item: T; index: number; selected: boolean; } |
Theme
export default defineAppConfig({
ui: {
pillGroup: {
slots: {
root: 'flex items-center gap-2',
list: 'relative flex group rounded-lg gap-1',
item: 'inline-flex items-center gap-1.5',
leading: 'inline-flex items-center shrink-0',
leadingIcon: 'shrink-0',
trailing: 'inline-flex items-center shrink-0',
trailingIcon: 'shrink-0',
itemWrapper: 'flex flex-col items-start min-w-0 gap-0.5',
itemLabel: 'truncate',
itemDescription: 'truncate text-xs'
},
variants: {
orientation: {
horizontal: {
root: 'flex-col',
list: 'w-full'
},
vertical: {
list: 'flex-col'
}
},
fieldGroup: {
horizontal: {
root: 'rounded-md overflow-hidden not-only:first:rounded-e-none not-only:last:rounded-s-none not-last:not-first:rounded-none focus-within:z-[1]',
list: 'rounded-md'
},
vertical: {
root: 'rounded-md overflow-hidden not-only:first:rounded-b-none not-only:last:rounded-t-none not-last:not-first:rounded-none focus-within:z-[1]',
list: 'rounded-md'
}
}
}
}
}
})