Demo
Related Components
This requires the following components to be installed:
Related Utils
This requires the following utils to be installed:
Related Types
This requires the following types to be installed:
Related Theme
This requires the following theme to be installed:
Component
vue
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import type { AvatarProps } from '@/ui/components/Avatar.vue'
import type { LinkProps } from '@/ui/components/Link.vue'
import type { DynamicSlots } from '@/ui/utils/utils'
import Avatar from '@/ui/components/Avatar.vue'
import Icon from '@/ui/components/Icon.vue'
import Link from '@/ui/components/Link.vue'
import { separatorIcon } from '@/ui/icons'
import theme from '@/ui/theme/breadcrumb'
import { get } from '@/ui/utils/get'
import { pickLinkProps } from '@/ui/utils/pick-link-props'
import { Primitive } from 'reka-ui'
import { tv } from 'tailwind-variants'
const breadcrumb = tv(theme)
export interface BreadcrumbItem extends Omit<LinkProps, 'raw' | 'custom'> {
label?: string
icon?: string
avatar?: AvatarProps
slot?: string
[key: string]: any
}
export interface BreadcrumbProps<T extends BreadcrumbItem = BreadcrumbItem> {
as?: any
items?: T[]
labelKey?: string
class?: any
ui?: Partial<typeof breadcrumb.slots>
}
type SlotProps<T extends BreadcrumbItem> = (props: { item: T, index: number, active?: boolean }) => any
export type BreadcrumbSlots<T extends BreadcrumbItem = BreadcrumbItem> = {
'item': SlotProps<T>
'item-leading': SlotProps<T>
'item-label': SlotProps<T>
'item-trailing': SlotProps<T>
'separator': any
} & DynamicSlots<T, 'leading' | 'label' | 'trailing', { index: number, active?: boolean }>
</script>
<script setup lang="ts" generic="T extends BreadcrumbItem">
const props = withDefaults(defineProps<BreadcrumbProps<T>>(), {
as: 'nav',
labelKey: 'label',
})
const slots = defineSlots<BreadcrumbSlots<T>>()
const ui = breadcrumb()
</script>
<template>
<Primitive :as="as" aria-label="breadcrumb" :class="ui.root({ class: [props.class, props.ui?.root] })">
<ol :class="ui.list({ class: props.ui?.list })">
<template v-for="(item, index) in items" :key="index">
<li :class="ui.item({ class: props.ui?.item })">
<Link v-bind="pickLinkProps({ ...item, as: 'span' })" :aria-current="item.active ? 'page' : ''" :class="ui.link({ class: [props.ui?.link, item.class], active: index === items!.length - 1, disabled: !!item.disabled, href: !!item.href })" raw>
<slot :name="((item.slot || 'item') as keyof BreadcrumbSlots<T>)" :item="item" :index="index">
<slot :name="((item.slot ? `${item.slot}-leading` : 'item-leading') as keyof BreadcrumbSlots<T>)" :item="item" :active="index === items!.length - 1" :index="index">
<Icon v-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ class: props.ui?.linkLeadingIcon, active: index === items!.length - 1 })" />
<Avatar v-else-if="item.avatar" :size="((props.ui?.linkLeadingAvatarSize || ui.linkLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ class: props.ui?.linkLeadingAvatar, active: index === items!.length - 1 })" />
</slot>
<span v-if="get(item, props.labelKey as string) || !!slots[(item.slot ? `${item.slot}-label` : 'item-label') as keyof BreadcrumbSlots<T>]" :class="ui.linkLabel({ class: props.ui?.linkLabel })">
<slot :name="((item.slot ? `${item.slot}-label` : 'item-label') as keyof BreadcrumbSlots<T>)" :item="item" :active="index === items!.length - 1" :index="index">
{{ get(item, props.labelKey as string) }}
</slot>
</span>
<slot :name="((item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof BreadcrumbSlots<T>)" :item="item" :active="index === items!.length - 1" :index="index" />
</slot>
</Link>
</li>
<li v-if="index < items!.length - 1" role="presentation" aria-hidden="true" :class="ui.separator({ class: props.ui?.separator })">
<slot name="separator">
<Icon :name="separatorIcon" :class="ui.separatorIcon({ class: props.ui?.separatorIcon })" />
</slot>
</li>
</template>
</ol>
</Primitive>
</template>vue
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import type { AvatarProps } from '@/UI/Components/Avatar.vue'
import type { LinkProps } from '@/UI/Components/Link.vue'
import type { DynamicSlots } from '@/UI/Utils/utils'
import Avatar from '@/UI/Components/Avatar.vue'
import Icon from '@/UI/Components/Icon.vue'
import Link from '@/UI/Components/Link.vue'
import { separatorIcon } from '@/UI/icons'
import theme from '@/UI/Theme/breadcrumb'
import { get } from '@/UI/Utils/get'
import { pickLinkProps } from '@/UI/Utils/pick-link-props'
import { Primitive } from 'reka-ui'
import { tv } from 'tailwind-variants'
const breadcrumb = tv(theme)
export interface BreadcrumbItem extends Omit<LinkProps, 'raw' | 'custom'> {
label?: string
icon?: string
avatar?: AvatarProps
slot?: string
[key: string]: any
}
export interface BreadcrumbProps<T extends BreadcrumbItem = BreadcrumbItem> {
as?: any
items?: T[]
labelKey?: string
class?: any
ui?: Partial<typeof breadcrumb.slots>
}
type SlotProps<T extends BreadcrumbItem> = (props: { item: T, index: number, active?: boolean }) => any
export type BreadcrumbSlots<T extends BreadcrumbItem = BreadcrumbItem> = {
'item': SlotProps<T>
'item-leading': SlotProps<T>
'item-label': SlotProps<T>
'item-trailing': SlotProps<T>
'separator': any
} & DynamicSlots<T, 'leading' | 'label' | 'trailing', { index: number, active?: boolean }>
</script>
<script setup lang="ts" generic="T extends BreadcrumbItem">
const props = withDefaults(defineProps<BreadcrumbProps<T>>(), {
as: 'nav',
labelKey: 'label',
})
const slots = defineSlots<BreadcrumbSlots<T>>()
const ui = breadcrumb()
</script>
<template>
<Primitive :as="as" aria-label="breadcrumb" :class="ui.root({ class: [props.class, props.ui?.root] })">
<ol :class="ui.list({ class: props.ui?.list })">
<template v-for="(item, index) in items" :key="index">
<li :class="ui.item({ class: props.ui?.item })">
<Link v-bind="pickLinkProps({ ...item, as: 'span' })" :aria-current="item.active ? 'page' : ''" :class="ui.link({ class: [props.ui?.link, item.class], active: index === items!.length - 1, disabled: !!item.disabled, href: !!item.href })" raw>
<slot :name="((item.slot || 'item') as keyof BreadcrumbSlots<T>)" :item="item" :index="index">
<slot :name="((item.slot ? `${item.slot}-leading` : 'item-leading') as keyof BreadcrumbSlots<T>)" :item="item" :active="index === items!.length - 1" :index="index">
<Icon v-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ class: props.ui?.linkLeadingIcon, active: index === items!.length - 1 })" />
<Avatar v-else-if="item.avatar" :size="((props.ui?.linkLeadingAvatarSize || ui.linkLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ class: props.ui?.linkLeadingAvatar, active: index === items!.length - 1 })" />
</slot>
<span v-if="get(item, props.labelKey as string) || !!slots[(item.slot ? `${item.slot}-label` : 'item-label') as keyof BreadcrumbSlots<T>]" :class="ui.linkLabel({ class: props.ui?.linkLabel })">
<slot :name="((item.slot ? `${item.slot}-label` : 'item-label') as keyof BreadcrumbSlots<T>)" :item="item" :active="index === items!.length - 1" :index="index">
{{ get(item, props.labelKey as string) }}
</slot>
</span>
<slot :name="((item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof BreadcrumbSlots<T>)" :item="item" :active="index === items!.length - 1" :index="index" />
</slot>
</Link>
</li>
<li v-if="index < items!.length - 1" role="presentation" aria-hidden="true" :class="ui.separator({ class: props.ui?.separator })">
<slot name="separator">
<Icon :name="separatorIcon" :class="ui.separatorIcon({ class: props.ui?.separatorIcon })" />
</slot>
</li>
</template>
</ol>
</Primitive>
</template>Theme
ts
export default {
slots: {
root: '',
list: '',
item: '',
link: '',
linkLeadingIcon: '',
linkLeadingAvatar: '',
linkLeadingAvatarSize: '',
linkLabel: '',
separator: '',
separatorIcon: '',
},
variants: {
active: {
true: {
link: '',
},
false: {
link: '',
},
},
disabled: {
true: {
link: '',
},
},
href: {
true: '',
},
},
compoundVariants: [],
}View Nuxt UI theme
ts
export default {
slots: {
root: 'relative min-w-0',
list: 'flex items-center gap-1.5',
item: 'flex min-w-0',
link: 'group relative flex items-center gap-1.5 text-sm min-w-0 focus-visible:outline-primary',
linkLeadingIcon: 'shrink-0 size-5',
linkLeadingAvatar: 'shrink-0',
linkLeadingAvatarSize: '2xs',
linkLabel: 'truncate',
separator: 'flex',
separatorIcon: 'shrink-0 size-5 text-muted',
},
variants: {
active: {
true: {
link: 'text-primary font-semibold',
},
false: {
link: 'text-muted font-medium',
},
},
disabled: {
true: {
link: 'cursor-not-allowed opacity-75',
},
},
href: {
true: '',
},
},
compoundVariants: [
{
disabled: false,
active: false,
href: true,
class: {
link: 'hover:text-default transition-colors',
},
},
],
}Test
To test this component, you can use the following test file:
ts
import Breadcrumb from '@/ui/components/Breadcrumb.vue'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
describe('breadcrumb', () => {
const items = [{
label: 'Home',
avatar: {
src: 'https://github.com/vue.png',
},
href: '/',
}, {
label: 'Components',
icon: 'i-lucide-box',
disabled: true,
}, {
label: 'Breadcrumb',
href: '/components/breadcrumb',
icon: 'i-lucide-link',
slot: 'custom',
}]
const props = { items }
it.each([
// Props
['with items', { props }],
['with labelKey', { props: { ...props, labelKey: 'icon' } }],
['with as', { props: { ...props, as: 'div' } }],
['with class', { props: { ...props, class: 'w-48' } }],
['with ui', { props: { ...props, ui: { link: 'font-bold' } } }],
// Slots
['with item slot', { props, slots: { item: () => 'Item slot' } }],
['with item-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }],
['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
['with custom slot', { props, slots: { custom: () => 'Custom slot' } }],
['with separator slot', { props, slots: { separator: () => '/' } }],
])('renders %s correctly', (name, options) => {
const { html } = render(Breadcrumb, options)
expect(html()).toMatchSnapshot()
})
})ts
import Breadcrumb from '@/UI/Components/Breadcrumb.vue'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
describe('breadcrumb', () => {
const items = [{
label: 'Home',
avatar: {
src: 'https://github.com/vue.png',
},
href: '/',
}, {
label: 'Components',
icon: 'i-lucide-box',
disabled: true,
}, {
label: 'Breadcrumb',
href: '/components/breadcrumb',
icon: 'i-lucide-link',
slot: 'custom',
}]
const props = { items }
it.each([
// Props
['with items', { props }],
['with labelKey', { props: { ...props, labelKey: 'icon' } }],
['with as', { props: { ...props, as: 'div' } }],
['with class', { props: { ...props, class: 'w-48' } }],
['with ui', { props: { ...props, ui: { link: 'font-bold' } } }],
// Slots
['with item slot', { props, slots: { item: () => 'Item slot' } }],
['with item-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }],
['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
['with custom slot', { props, slots: { custom: () => 'Custom slot' } }],
['with separator slot', { props, slots: { separator: () => '/' } }],
])('renders %s correctly', (name, options) => {
const { html } = render(Breadcrumb, options)
expect(html()).toMatchSnapshot()
})
})