diff --git a/packages/api-generator/src/locale/en/VBreadcrumbs.json b/packages/api-generator/src/locale/en/VBreadcrumbs.json index 6bc0747f73b..07630abb120 100644 --- a/packages/api-generator/src/locale/en/VBreadcrumbs.json +++ b/packages/api-generator/src/locale/en/VBreadcrumbs.json @@ -1,14 +1,18 @@ { "props": { + "collapseInMenu": "When true, overflowing breadcrumb items are collapsed into a contextual menu rather than a static ellipsis.", "divider": "Specifies the dividing character between items.", + "ellipsis": "Text displayed when breadcrumb items collapsed.", "icons": "Specifies that the dividers between items are [v-icon](/components/icons)s.", "justifyCenter": "Align the breadcrumbs center.", "justifyEnd": "Align the breadcrumbs at the end.", - "large": "Increase the font-size of the breadcrumb item text to 16px (14px default)." + "large": "Increase the font-size of the breadcrumb item text to 16px (14px default).", + "totalVisible": "Determines how many breadcrumb items can be shown before middle items are collapsed." }, "slots": { "divider": "The slot used for dividers.", "prepend": "The slot used for prepend content.", - "title": "The slot used to display the title of each breadcrumb." + "title": "The slot used to display the title of each breadcrumb.", + "listItem": "The slot used for customizing each item inside the contextual menu when breadcrumbs items are collapsed." } } diff --git a/packages/docs/src/examples/v-breadcrumbs/slot-collapse-in-menu.vue b/packages/docs/src/examples/v-breadcrumbs/slot-collapse-in-menu.vue new file mode 100644 index 00000000000..122915ebbd2 --- /dev/null +++ b/packages/docs/src/examples/v-breadcrumbs/slot-collapse-in-menu.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/packages/docs/src/examples/v-breadcrumbs/slot-list-item.vue b/packages/docs/src/examples/v-breadcrumbs/slot-list-item.vue new file mode 100644 index 00000000000..513522c2479 --- /dev/null +++ b/packages/docs/src/examples/v-breadcrumbs/slot-list-item.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/packages/docs/src/examples/v-breadcrumbs/slot-total-visible.vue b/packages/docs/src/examples/v-breadcrumbs/slot-total-visible.vue new file mode 100644 index 00000000000..466b628d3be --- /dev/null +++ b/packages/docs/src/examples/v-breadcrumbs/slot-total-visible.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/packages/docs/src/pages/en/components/breadcrumbs.md b/packages/docs/src/pages/en/components/breadcrumbs.md index ad7ce1f360a..bf255240624 100644 --- a/packages/docs/src/pages/en/components/breadcrumbs.md +++ b/packages/docs/src/pages/en/components/breadcrumbs.md @@ -78,3 +78,21 @@ To customize the divider, use the `divider` slot. You can use the `title` slot to customize each breadcrumb title. + +#### Collapsed breadcrumbs + +You can use the `totalVisible` prop to redefine the maximum number of breadcrumb items displayed before the component collapses. + + + +#### Collapsed with menu + +You can use the `collapseInMenu` prop to display the collapsed breadcrumb items inside a dropdown menu. + + + +#### Collapsed with custom menu + +You can use the `list-item` slot to customize how each breadcrumb item is rendered inside the collapsed menu when `collapseInMenu` is enabled. + + diff --git a/packages/vuetify/playgrounds/Playground.breadcrumbs.vue b/packages/vuetify/playgrounds/Playground.breadcrumbs.vue new file mode 100644 index 00000000000..1b99e70780c --- /dev/null +++ b/packages/vuetify/playgrounds/Playground.breadcrumbs.vue @@ -0,0 +1,26 @@ + + + diff --git a/packages/vuetify/src/components/VBreadcrumbs/VBreadcrumbs.sass b/packages/vuetify/src/components/VBreadcrumbs/VBreadcrumbs.sass index edae8df1829..9c42f581714 100644 --- a/packages/vuetify/src/components/VBreadcrumbs/VBreadcrumbs.sass +++ b/packages/vuetify/src/components/VBreadcrumbs/VBreadcrumbs.sass @@ -5,6 +5,7 @@ .v-breadcrumbs display: flex align-items: center + flex-wrap: wrap line-height: $breadcrumbs-line-height padding: $breadcrumbs-padding-y $breadcrumbs-padding-x @@ -28,6 +29,9 @@ text-decoration: none vertical-align: $breadcrumbs-vertical-align + &--ellipsis + cursor: pointer + &--disabled opacity: $breadcrumbs-item-disabled-opacity pointer-events: none diff --git a/packages/vuetify/src/components/VBreadcrumbs/VBreadcrumbs.tsx b/packages/vuetify/src/components/VBreadcrumbs/VBreadcrumbs.tsx index 27f7f73e6ec..e780d3ec6e7 100644 --- a/packages/vuetify/src/components/VBreadcrumbs/VBreadcrumbs.tsx +++ b/packages/vuetify/src/components/VBreadcrumbs/VBreadcrumbs.tsx @@ -4,8 +4,10 @@ import './VBreadcrumbs.sass' // Components import { VBreadcrumbsDivider } from './VBreadcrumbsDivider' import { VBreadcrumbsItem } from './VBreadcrumbsItem' +import { VIcon } from '../VIcon' +import { VList, VListItem, VListItemTitle } from '../VList' +import { VMenu } from '../VMenu' import { VDefaultsProvider } from '@/components/VDefaultsProvider' -import { VIcon } from '@/components/VIcon' // Composables import { useBackgroundColor } from '@/composables/color' @@ -17,8 +19,8 @@ import { makeRoundedProps, useRounded } from '@/composables/rounded' import { makeTagProps } from '@/composables/tag' // Utilities -import { computed, toRef } from 'vue' -import { genericComponent, isObject, propsFactory, useRender } from '@/util' +import { computed, ref, toRef, watch } from 'vue' +import { genericComponent, isObject, noop, propsFactory, useRender } from '@/util' // Types import type { PropType } from 'vue' @@ -36,23 +38,35 @@ export const makeVBreadcrumbsProps = propsFactory({ activeClass: String, activeColor: String, bgColor: String, + collapseInMenu: Boolean, color: String, disabled: Boolean, divider: { type: String, default: '/', }, + ellipsis: { + type: String, + default: '...', + }, icon: IconValue, items: { type: Array as PropType, default: () => ([]), }, itemProps: Boolean, + listProps: { + type: Object as PropType, + }, + menuProps: { + type: Object as PropType, + }, + totalVisible: Number, ...makeComponentProps(), ...makeDensityProps(), ...makeRoundedProps(), - ...makeTagProps({ tag: 'ul' }), + ...makeTagProps({ tag: 'nav' }), }, 'VBreadcrumbs') export const VBreadcrumbs = genericComponent( @@ -64,6 +78,7 @@ export const VBreadcrumbs = genericComponent( title: { item: InternalBreadcrumbItem, index: number } divider: { item: T, index: number } item: { item: InternalBreadcrumbItem, index: number } + 'list-item': { item: InternalBreadcrumbItem, index: number } default: never } ) => GenericProps>()({ @@ -91,12 +106,23 @@ export const VBreadcrumbs = genericComponent( const items = computed(() => props.items.map(item => { return typeof item === 'string' ? { item: { title: item }, raw: item } : { item, raw: item } })) + const ellipsisEnabled = toRef(() => props.totalVisible ? items.value.length > props.totalVisible : false) + const hasEllipsis = ref(ellipsisEnabled.value) + + const onClickEllipsis = () => { + hasEllipsis.value = false + } + + watch(ellipsisEnabled, (value: boolean) => { + hasEllipsis.value = value + }) useRender(() => { const hasPrepend = !!(slots.prepend || props.icon) return ( ( props.style, ]} > +
    { hasPrepend && (
  1. { !slots.prepend ? ( @@ -133,7 +160,7 @@ export const VBreadcrumbs = genericComponent(
  2. )} - { items.value.map(({ item, raw }, index, array) => ( + { !hasEllipsis.value && items.value.map(({ item, raw }, index, array) => ( <> { slots.item?.({ item, index }) ?? ( ( ))} + { hasEllipsis.value && ( + <> + { (() => { + const { item } = items.value[0] + return ( + <> + { slots.item?.({ item, index: 0 }) ?? ( + + )} + + ) + })()} + + + + + { props.ellipsis } + { props.collapseInMenu ? ( + + {{ + default: () => ( + + { items.value.slice(1, items.value.length - 1).map(({ item }, index) => { + if (slots['list-item']) { + return slots['list-item']({ item, index }) + } + return ( + + { item.title } + + ) + })} + + ), + }} + + ) : null } + + + + + { (() => { + const lastIndex = items.value.length - 1 + const { item } = items.value[lastIndex] + return ( + <> + { slots.item?.({ item, index: lastIndex }) ?? ( + + )} + + ) + })()} + + )} + { slots.default?.() } +
) }) diff --git a/packages/vuetify/src/components/VBreadcrumbs/__tests__/VBreadcrumbs.spec.browser.tsx b/packages/vuetify/src/components/VBreadcrumbs/__tests__/VBreadcrumbs.spec.browser.tsx index a606a365f60..427db8492cd 100644 --- a/packages/vuetify/src/components/VBreadcrumbs/__tests__/VBreadcrumbs.spec.browser.tsx +++ b/packages/vuetify/src/components/VBreadcrumbs/__tests__/VBreadcrumbs.spec.browser.tsx @@ -4,7 +4,7 @@ import { VBreadcrumbsDivider } from '../VBreadcrumbsDivider' import { VBreadcrumbsItem } from '../VBreadcrumbsItem' // Utilities -import { render, screen } from '@test' +import { render, screen, userEvent } from '@test' describe('VBreadcrumbs', () => { it('should use item slot', async () => { @@ -110,4 +110,75 @@ describe('VBreadcrumbs', () => { expect(screen.getByText('/')).toBeVisible() expect(screen.getByText('-')).toBeVisible() }) + + it('should collapse into ellipsis when items exceed totalVisible', async () => { + render(() => ( + + )) + + const ellipsis = screen.getByText('...') + expect(ellipsis).toBeVisible() + expect(ellipsis).toHaveClass('v-breadcrumbs-item--ellipsis') + }) + + it('should expand all items when ellipsis is clicked and collapseInMenu = false', async () => { + render(() => ( + + )) + + const ellipsis = screen.getByText('...') + expect(ellipsis).toBeVisible() + + await userEvent.click(ellipsis) + + const items = screen.getAllByCSS('.v-breadcrumbs-item') + expect(items).toHaveLength(8) + }) + + it('should show a VMenu when collapseInMenu = true', async () => { + render(() => ( + + )) + + const ellipsis = screen.getByText('...') + expect(ellipsis).toBeVisible() + + await userEvent.click(ellipsis) + + // Le menu doit maintenant รชtre rendu + const listItems = screen.getAllByText(/b|c/) + expect(listItems).toHaveLength(2) + }) + + it('should not use VMenu when collapseInMenu = false', () => { + render(() => ( + + )) + + const ellipsis = screen.getByText('...') + expect(ellipsis).toBeVisible() + + const menu = screen.queryByCSS('.v-menu') + expect(menu).toBeNull() + }) })