diff --git a/pages/breadcrumb-group/responsive.page.tsx b/pages/breadcrumb-group/responsive.page.tsx index 89cdd9c55d..01c1178d4c 100644 --- a/pages/breadcrumb-group/responsive.page.tsx +++ b/pages/breadcrumb-group/responsive.page.tsx @@ -20,7 +20,7 @@ export default function ResponsiveBreadcrumbsPage() { { - test( - 'Has proper number of items in the dropdown', + test.each([ + [{ width: 770, height: 800 }, 1], + [{ width: 740, height: 800 }, 2], + [{ width: 680, height: 800 }, 3], + [{ width: 610, height: 800 }, 4], + [{ width: 550, height: 800 }, 6], + ])('Has proper number of items in the dropdown: %o %d', (sizes, itemsInDropdown) => setupTest(async page => { - await page.setWindowSize({ width: 645, height: 800 }); - await page.openDropdown(); - expect(await page.getElementsCount(dropdownItemsSelector)).toBe(1); - await page.closeDropdown(); - - await page.setWindowSize({ width: 570, height: 800 }); await page.openDropdown(); - expect(await page.getElementsCount(dropdownItemsSelector)).toBe(2); - await page.closeDropdown(); - - await page.setWindowSize({ width: 500, height: 800 }); - await page.openDropdown(); - expect(await page.getElementsCount(dropdownItemsSelector)).toBe(3); - await page.closeDropdown(); - - await page.setWindowSize({ width: 400, height: 800 }); - await page.openDropdown(); - expect(await page.getElementsCount(dropdownItemsSelector)).toBe(4); - }) + expect(await page.getElementsCount(dropdownItemsSelector)).toBe(itemsInDropdown); + }, sizes)() ); test( 'Does not return ghost items', @@ -105,7 +94,7 @@ describe('BreadcrumbGroup', () => { test( 'Adjusts display when adding/removing items', setupTest(async page => { - await page.setWindowSize({ width: 700, height: 800 }); + await page.setWindowSize({ width: 950, height: 800 }); expect(page.isEllipsisVisible()).resolves.toBe(false); await page.click('#add'); expect(page.isEllipsisVisible()).resolves.toBe(true); @@ -134,104 +123,35 @@ describe('BreadcrumbGroup', () => { await page.openDropdown(); await expect(page.getText('#onFollowMessage')).resolves.toEqual(''); await expect(page.getText('#onClickMessage')).resolves.toEqual(''); - await page.clickItem(1); + await page.clickItem(2); await expect(page.getText('#onFollowMessage')).resolves.toEqual('OnFollow: Second item was selected'); await expect(page.getText('#onClickMessage')).resolves.toEqual('OnClick: Second item was selected'); }) ); - describe('Item popover', () => { + describe.each([[true], [false]])('analytics attributes (mobile: %p)', mobile => { test( - 'should be displayed for truncated items on first render', - setupTest( - async page => { - await page.click('#focus-target-long-text'); - await page.keys('Tab'); - await expect(page.isTooltipDisplayed()).resolves.toBe(true); - await page.keys('Tab'); - await expect(page.isTooltipDisplayed()).resolves.toBe(false); - }, - { width: 100, height: 800 } - ) - ); - - test( - 'should not be displayed for non-truncated items on first render', - setupTest( - async page => { - await page.click('#focus-target-long-text'); - await page.keys('Tab'); - await expect(page.isTooltipDisplayed()).resolves.toBe(false); - }, - { width: 1200, height: 800 } - ) - ); - - test( - 'should be displayed for truncated items after resizing', - setupTest( - async page => { - await page.setWindowSize({ width: 1000, height: 800 }); - await page.click('#focus-target-long-text'); - await page.keys('Tab'); - await expect(page.isTooltipDisplayed()).resolves.toBe(true); - await page.keys('Tab'); - await expect(page.isTooltipDisplayed()).resolves.toBe(false); - }, - { width: 1200, height: 800 } - ) - ); - test( - 'should be displayed for truncated items after collapsing items into dropdown', - setupTest( - async page => { + 'attaches funnel name attribute', + setupTest(async (page, browser) => { + if (mobile) { await page.setMobileViewport(); - await page.click('#focus-target-long-text'); - await page.keys('Tab'); - await expect(page.isTooltipDisplayed()).resolves.toBe(true); - }, - { width: 1200, height: 800 } - ) + } + const funnelName = await browser.$('[data-analytics-funnel-key="funnel-name"]').getText(); + expect(funnelName).toBe('Sixth that is very very very very very very long long long text'); + }) ); test( - 'should not be displayed after making the viewport larger again', - setupTest( - async page => { + 'attaches resource name attribute', + setupTest(async (page, browser) => { + if (mobile) { await page.setMobileViewport(); - await page.setWindowSize({ width: 1200, height: 800 }); - await page.click('#focus-target-long-text'); - await page.keys('Tab'); - await expect(page.isTooltipDisplayed()).resolves.toBe(false); - await page.keys('Tab'); - await expect(page.isTooltipDisplayed()).resolves.toBe(false); - await page.keys('Tab'); - }, - { width: 1200, height: 800 } - ) - ); - - test( - 'Item popover should close after pressing Escape', - setupTest(async page => { - await page.setMobileViewport(); - await page.click('#focus-target-long-text'); - await page.keys('Tab'); - await expect(page.isTooltipDisplayed()).resolves.toBe(true); - await page.keys('Escape'); - await expect(page.isTooltipDisplayed()).resolves.toBe(false); + } + const funnelName = await browser.$('[data-analytics-funnel-resource-type="true"]').getHTML(); + expect(funnelName).toMatch('>Second<'); }) ); }); - test( - 'Attaches funnel name attribute to last breadcrumb item', - setupTest(async (page, browser) => { - await page.setMobileViewport(); - const funnelName = await browser.$('[data-analytics-funnel-key="funnel-name"]').getText(); - expect(funnelName).toBe('Sixth that is very very very very very very long long long text'); - }) - ); - test( 'Focus does not go into ghost replica', setupTest( @@ -251,48 +171,21 @@ describe('BreadcrumbGroup', () => { test( 'Last item is focusable when truncated', - setupTest(async page => { - await page.setMobileViewport(); - await page.click('#focus-target-long-text'); - await page.keys('Tab'); - await page.keys('Tab'); - await page.keys('Tab'); - await expect(page.isTooltipDisplayed()).resolves.toBe(true); - await page.keys('Tab'); - await expect(page.isTooltipDisplayed()).resolves.toBe(false); - await expect(page.getActiveElementId()).resolves.toBe('focus-target-short-text'); - }) - ); - - test( - 'Displays only one tooltip at the time', - setupTest(async page => { - await page.setMobileViewport(); - await page.click('#focus-target-long-text'); - await page.keys('Tab'); - await expect(page.countTooltips()).resolves.toBe(1); - await expect(page.getTooltipText()).resolves.toBe( - 'First that is very very very very very very long long long text' - ); - await page.hoverElement(breadcrumbGroupWrapper.findBreadcrumbLink(6).toSelector()); - await expect(page.countTooltips()).resolves.toBe(1); - await expect(page.getTooltipText()).resolves.toBe( - 'Sixth that is very very very very very very long long long text' - ); - - await page.hoverElement('#focus-target-long-text'); - await page.click('#focus-target-long-text'); - await expect(page.countTooltips()).resolves.toBe(0); - await page.hoverElement(breadcrumbGroupWrapper.findBreadcrumbLink(6).toSelector()); - await expect(page.countTooltips()).resolves.toBe(1); - await expect(page.getTooltipText()).resolves.toBe( - 'Sixth that is very very very very very very long long long text' - ); - await page.keys('Tab'); - await expect(page.countTooltips()).resolves.toBe(1); - await expect(page.getTooltipText()).resolves.toBe( - 'First that is very very very very very very long long long text' - ); - }) + setupTest( + async page => { + await page.click('#focus-target-long-text'); + await page.keys('Tab'); + await page.keys('Tab'); + await page.keys('Tab'); + await page.keys('Tab'); + await page.keys('Tab'); + await page.keys('Tab'); + await expect(page.isTooltipDisplayed()).resolves.toBe(true); + await page.keys('Tab'); + await expect(page.isTooltipDisplayed()).resolves.toBe(false); + await expect(page.getActiveElementId()).resolves.toBe('focus-target-short-text'); + }, + { width: 950, height: 800 } + ) ); }); diff --git a/src/breadcrumb-group/__tests__/breadcrumb-group.test.tsx b/src/breadcrumb-group/__tests__/breadcrumb-group.test.tsx index 2754297c99..55d3798806 100644 --- a/src/breadcrumb-group/__tests__/breadcrumb-group.test.tsx +++ b/src/breadcrumb-group/__tests__/breadcrumb-group.test.tsx @@ -11,6 +11,21 @@ import createWrapper, { BreadcrumbGroupWrapper, ElementWrapper } from '../../../ import itemStyles from '../../../lib/components/breadcrumb-group/item/styles.css.js'; import styles from '../../../lib/components/breadcrumb-group/styles.css.js'; +let mockMobileViewport = false; +jest.mock('@cloudscape-design/component-toolkit', () => { + return { + ...jest.requireActual('@cloudscape-design/component-toolkit'), + useContainerQuery: () => (mockMobileViewport ? [10, () => {}] : [9999, () => {}]), + }; +}); +jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ + ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), + getLogicalBoundingClientRect: () => ({ inlineSize: 50 }), +})); +afterEach(() => { + mockMobileViewport = false; +}); + const renderBreadcrumbGroup = (props: BreadcrumbGroupProps) => { const renderResult = render(); return createWrapper(renderResult.container).findBreadcrumbGroup()!; @@ -70,6 +85,27 @@ describe('BreadcrumbGroup Component', () => { }); }); + test('renders with items (mobile)', () => { + mockMobileViewport = true; + wrapper = renderBreadcrumbGroup({ items }); + expect(wrapper.getElement().nodeName).toBe('NAV'); + + const dropdown = wrapper.findDropdown()!; + dropdown.openDropdown(); + const links = dropdown.findItems(); + expect(links).toHaveLength(3); + + links.forEach((link, i) => { + expect(link.getElement()).toHaveTextContent(items[i].text); + if (i === links.length - 1) { + // last item should not have an href + expect(link.getElement().querySelector('a')).toBeFalsy(); + } else { + expect(link.getElement().querySelector('a')).toHaveAttribute('href', items[i].href); + } + }); + }); + test('has ellipsis', () => { expect(wrapper.findDropdown()!.findNativeButton()).not.toBe(null); expect(wrapper.findByClassName(styles.ellipsis)!.getElement()).toBeInTheDocument(); @@ -121,9 +157,25 @@ describe('BreadcrumbGroup Component', () => { rerender(); expect(getIcons()).toHaveLength(2); }); + + test('clicking current page in mobile dropdown should close dropdown without events', () => { + mockMobileViewport = true; + const onClick = jest.fn(); + const onFollow = jest.fn(); + const { container } = render(); + const wrapper = createWrapper(container).findBreadcrumbGroup()!; + const dropdown = wrapper.findDropdown()!; + dropdown.openDropdown(); + expect(dropdown.findItems().length).toBe(3); + dropdown.findItems()[2].click(); + expect(dropdown.findOpenDropdown()).toBeFalsy(); + expect(onClick).not.toHaveBeenCalled(); + expect(onFollow).not.toHaveBeenCalled(); + }); }); - test('supports extended items object', () => { + test.each([[true], [false]])('supports extended items object (mobile: %p)', mobile => { + mockMobileViewport = mobile; interface ExtendedItem extends BreadcrumbGroupProps.Item { metadata: number; } @@ -143,7 +195,13 @@ describe('BreadcrumbGroup Component', () => { /> ); const wrapper = createWrapper(container).findBreadcrumbGroup()!; - wrapper.findBreadcrumbLink(2)!.click(); + if (mobile) { + const dropdown = wrapper.findDropdown()!; + dropdown.openDropdown(); + dropdown.findItems()[1].click(); + } else { + wrapper.findBreadcrumbLink(2)!.click(); + } expect(onClick).toHaveBeenCalledWith(items[1]); }); @@ -233,7 +291,10 @@ describe('BreadcrumbGroup Component', () => { }); }); - describe('funnel attributes', () => { + describe.each([[true], [false]])('funnel attributes (mobile: %p)', mobile => { + beforeEach(() => { + mockMobileViewport = mobile; + }); function getElementsText(elements: Array) { return Array.from(elements).map(element => element.getElement().textContent); } diff --git a/src/breadcrumb-group/__tests__/utils.test.ts b/src/breadcrumb-group/__tests__/utils.test.ts index c455b955dd..556202dcbb 100644 --- a/src/breadcrumb-group/__tests__/utils.test.ts +++ b/src/breadcrumb-group/__tests__/utils.test.ts @@ -6,94 +6,62 @@ describe('getItemsDisplayProperties', () => { test('does not break with zero items', () => { const displayProperties = getItemsDisplayProperties([], 90); expect(displayProperties).toEqual({ - shrinkFactors: [], - minWidths: [], collapsed: 0, }); }); - test('returns correct shrinkFactors', () => { - const { shrinkFactors } = getItemsDisplayProperties([20, 300, 0, 1000, 150], 0); - expect(shrinkFactors).toEqual([0, 300, 0, 1000, 0]); + test('two items: enough space', () => { + expect(getItemsDisplayProperties([70, 30], 100).collapsed).toEqual(0); }); - test('returns correct minWidths', () => { - const { minWidths } = getItemsDisplayProperties([20, 300, 0, 1000, 150], 0); - expect(minWidths).toEqual([0, 150, 0, 150, 0]); + test('two items: not enough space', () => { + expect(getItemsDisplayProperties([70, 30], 99).collapsed).toEqual(1); }); - test('returns correct number of collapsed items', () => { - const itemsWidths = [20, 300, 500, 60, 150, 1000]; - [ - [3000, 0], - [730, 0], - [729, 1], // second breadcrumb collapses - [580, 1], - [579, 2], // third breadcrumb collapses - [430, 2], - [429, 3], // fourth breadcrumb collapses - [370, 3], - [369, 4], // fifth breadcrumb collapses - [250, 4], - [10, 4], - [-10, 4], - ].forEach(([navWidth, expectedCollapsed]) => - expect(getItemsDisplayProperties(itemsWidths, navWidth).collapsed).toEqual(expectedCollapsed) - ); + test('two items: enough space with final item truncation', () => { + expect(getItemsDisplayProperties([70, 100], 140).collapsed).toEqual(0); }); - describe('adjusts to small container widths', () => { - describe('with one item', () => { - test('smaller than default min width and bigger than available width', () => { - expect(getItemsDisplayProperties([100], 90)).toEqual({ - shrinkFactors: [100], - minWidths: [90], - collapsed: 0, - }); - }); - test('smaller than default min width and smaller than available width', () => { - expect(getItemsDisplayProperties([80], 90)).toEqual({ - shrinkFactors: [0], - minWidths: [0], - collapsed: 0, - }); - }); - - test('bigger than default min width and bigger than available width', () => { - expect(getItemsDisplayProperties([160], 90)).toEqual({ - shrinkFactors: [160], - minWidths: [90], - collapsed: 0, - }); - }); - }); + test('two items: not enough space with final item truncation', () => { + expect(getItemsDisplayProperties([70, 100], 139).collapsed).toEqual(1); }); - describe('with two items', () => { - test('both bigger than default min width and bigger than available width', () => { - expect(getItemsDisplayProperties([160, 160], 90)).toEqual({ - shrinkFactors: [160, 160], - minWidths: [45, 45], - collapsed: 0, - }); - }); - test('both smaller than default min width and bigger than available width', () => { - expect(getItemsDisplayProperties([100, 100], 90)).toEqual({ - shrinkFactors: [100, 100], - minWidths: [45, 45], - collapsed: 0, - }); - }); + test('three items: enough space', () => { + expect(getItemsDisplayProperties([70, 70, 30], 170).collapsed).toEqual(0); }); - describe('with more than two items', () => { - test('first and last bigger than default min width and bigger than available width', () => { - expect(getItemsDisplayProperties([160, 200, 20, 140, 160], 320)).toEqual({ - shrinkFactors: [160, 200, 0, 140, 160], - minWidths: [135, 135, 0, 135, 135], - collapsed: 3, - }); - }); - test('first and last smaller than default min width and bigger than available width', () => { - expect(getItemsDisplayProperties([140, 200, 20, 140, 140], 320)).toEqual({ - shrinkFactors: [140, 200, 0, 140, 140], - minWidths: [135, 135, 0, 135, 135], - collapsed: 3, - }); - }); + test('three items: not enough space (collapse 1)', () => { + expect(getItemsDisplayProperties([70, 70, 30], 169).collapsed).toEqual(1); + expect(getItemsDisplayProperties([70, 70, 30], 150).collapsed).toEqual(1); + }); + test('three items: not enough space (collapse 2)', () => { + expect(getItemsDisplayProperties([70, 70, 30], 149).collapsed).toEqual(2); + }); + test('three items: enough space with final item truncation', () => { + expect(getItemsDisplayProperties([70, 70, 100], 210).collapsed).toEqual(0); + }); + test('three items: not enough space with final item truncation (collapse 1)', () => { + expect(getItemsDisplayProperties([70, 70, 100], 209).collapsed).toEqual(1); + expect(getItemsDisplayProperties([70, 70, 100], 190).collapsed).toEqual(1); + }); + test('three items: not enough space with final item truncation (collapse 2)', () => { + expect(getItemsDisplayProperties([70, 70, 100], 189).collapsed).toEqual(2); + }); + test('three items, small 2nd: enough space', () => { + expect(getItemsDisplayProperties([70, 30, 30], 130).collapsed).toEqual(0); + }); + test('three items, small 2nd: not enough space', () => { + expect(getItemsDisplayProperties([70, 30, 30], 129).collapsed).toEqual(2); + }); + test.each([ + [3000, 0], + [1090, 0], + [1089, 1], // second breadcrumb collapses + [840, 1], + [839, 2], // third breadcrumb collapses + [340, 2], + [339, 3], // fourth breadcrumb collapses + [290, 3], + [289, 4], // fifth breadcrumb collapses + [140, 4], + [139, 5], // sixth breadcrumb collapses + [10, 5], + ])('returns correct number of collapsed items (width: %d, collapsed: %d)', (navWidth, expectedCollapsed) => { + const itemsWidths = [20, 300, 500, 50, 150, 1000]; + expect(getItemsDisplayProperties(itemsWidths, navWidth).collapsed).toEqual(expectedCollapsed); }); }); diff --git a/src/breadcrumb-group/all-items-dropdown.tsx b/src/breadcrumb-group/all-items-dropdown.tsx new file mode 100644 index 0000000000..53144a0101 --- /dev/null +++ b/src/breadcrumb-group/all-items-dropdown.tsx @@ -0,0 +1,85 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { Ref } from 'react'; +import clsx from 'clsx'; + +import { CustomTriggerProps } from '../button-dropdown/interfaces'; +import InternalButtonDropdown from '../button-dropdown/internal'; +import InternalIcon from '../icon/internal'; +import { DATA_ATTR_FUNNEL_KEY, DATA_ATTR_RESOURCE_TYPE, FUNNEL_KEY_FUNNEL_NAME } from '../internal/analytics/selectors'; +import { CancelableEventHandler } from '../internal/events'; +import { spinWhenOpen } from '../internal/styles/motion/utils'; +import { BreadcrumbGroupProps } from './interfaces'; + +import styles from './styles.css.js'; + +interface FullCollapsedDropdownProps { + items: ReadonlyArray; + onItemClick: CancelableEventHandler<{ id: string }>; + onItemFollow: CancelableEventHandler<{ id: string }>; +} + +const metadataTypeAttribute = { + [DATA_ATTR_RESOURCE_TYPE]: 'true', +}; + +export const AllItemsDropdown = ({ items, onItemClick, onItemFollow }: FullCollapsedDropdownProps) => ( + <> + { + const isCurrentBreadcrumb = index === items.length - 1; + return { + id: index.toString(), + text: item.text, + href: isCurrentBreadcrumb ? undefined : item.href, + isCurrentBreadcrumb, + }; + })} + customTriggerBuilder={getDropdownTrigger(items[items.length - 1]?.text)} + linkStyle={true} + fullWidth={true} + onItemClick={onItemClick} + onItemFollow={onItemFollow} + analyticsMetadataTransformer={metadata => { + if (metadata.detail?.id) { + delete metadata.detail.id; + } + if (metadata.detail?.position) { + metadata.detail.position = `${parseInt(metadata.detail.position as string, 10) + 1}`; + } + return metadata; + }} + /> + {/* Second breadcrumb item is tagged as "resource type" */} + {items.length > 1 && ( + + {items[1].text} + + )} + +); +const getDropdownTrigger = + (currentPage: string) => + ({ ariaLabel, triggerRef, testUtilsClass, isOpen, onClick }: CustomTriggerProps) => { + const metadataAttributes = { + [DATA_ATTR_FUNNEL_KEY]: FUNNEL_KEY_FUNNEL_NAME, + }; + return ( + + ); + }; diff --git a/src/breadcrumb-group/implementation.tsx b/src/breadcrumb-group/implementation.tsx index 13768b8c91..f3d49d03ec 100644 --- a/src/breadcrumb-group/implementation.tsx +++ b/src/breadcrumb-group/implementation.tsx @@ -17,6 +17,7 @@ import { fireCancelableEvent } from '../internal/events'; import { useMergeRefs } from '../internal/hooks/use-merge-refs'; import { checkSafeUrl } from '../internal/utils/check-safe-url'; import { createWidgetizedComponent } from '../internal/widgets'; +import { AllItemsDropdown } from './all-items-dropdown'; import { GeneratedAnalyticsMetadataBreadcrumbGroupClick, GeneratedAnalyticsMetadataBreadcrumbGroupComponent, @@ -34,19 +35,11 @@ import styles from './styles.css.js'; */ const DEFAULT_EXPAND_ARIA_LABEL = 'Show path'; -const getDropdownTrigger = ({ - ariaLabel, - triggerRef, - disabled, - testUtilsClass, - isOpen, - onClick, -}: CustomTriggerProps) => { +const getEllipsisDropdownTrigger = ({ ariaLabel, triggerRef, testUtilsClass, isOpen, onClick }: CustomTriggerProps) => { return ( { event.preventDefault(); onClick(); @@ -78,7 +71,7 @@ const EllipsisDropdown = ({ items={dropdownItems} onItemClick={onDropdownItemClick} onItemFollow={onDropdownItemFollow} - customTriggerBuilder={getDropdownTrigger} + customTriggerBuilder={getEllipsisDropdownTrigger} linkStyle={true} analyticsMetadataTransformer={metadata => { if (metadata.detail?.id) { @@ -166,7 +159,7 @@ export function BreadcrumbGroupImplementation { const isLast = index === items.length - 1; @@ -185,7 +178,6 @@ export function BreadcrumbGroupImplementation setBreadcrumb('real', `${index}`, node)} > -
    {breadcrumbItems}
+ {collapsed > 0 && collapsed === items.length - 1 ? ( + + e.detail.id !== (items.length - 1).toString() && + fireCancelableEvent(onClick, getEventDetail(getEventItem(e)), e) + } + onItemFollow={e => + e.detail.id !== (items.length - 1).toString() && + fireCancelableEvent(onFollow, getEventDetail(getEventItem(e)), e) + } + /> + ) : ( +
    {breadcrumbItems}
+ )}
    {hiddenBreadcrumbItems}
diff --git a/src/breadcrumb-group/styles.scss b/src/breadcrumb-group/styles.scss index 01ae3287e5..731263c7f7 100644 --- a/src/breadcrumb-group/styles.scss +++ b/src/breadcrumb-group/styles.scss @@ -5,6 +5,7 @@ @use '../internal/styles' as styles; @use '../internal/styles/tokens' as awsui; +@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; .breadcrumb-group { @include styles.styles-reset; @@ -40,6 +41,11 @@ margin-inline: 0; } + > .item:last-child { + flex-shrink: 1; + min-inline-size: 0; + } + > .item.hide { display: none; } @@ -64,3 +70,44 @@ .breadcrumbs-skeleton { display: none; } + +.collapsed-button { + @include styles.styles-reset; + @include styles.text-wrapping; + @include styles.font-button; + letter-spacing: awsui.$font-button-letter-spacing; + color: awsui.$color-text-interactive-default; + cursor: pointer; + padding-block: 0; + padding-inline: 0; + border-inline: none; + border-block: none; + background: none; + display: flex; + gap: awsui.$space-xxs; + max-inline-size: 100%; + @include focus-visible.when-visible { + @include styles.focus-highlight(awsui.$space-button-focus-outline-gutter); + } + &:hover { + color: awsui.$color-text-interactive-hover; + } + > :last-child { + color: awsui.$color-text-breadcrumb-current; + font-weight: styles.$font-weight-bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + &:hover { + color: awsui.$color-text-interactive-hover; + } + } +} + +.button-icon { + @include styles.spin-180-when-open; +} + +.hidden { + display: none; +} diff --git a/src/breadcrumb-group/utils.ts b/src/breadcrumb-group/utils.ts index c0c72a90fb..2fdb2d1bdd 100644 --- a/src/breadcrumb-group/utils.ts +++ b/src/breadcrumb-group/utils.ts @@ -8,63 +8,36 @@ export const getEventDetail = (item: T) => href: item.href, }); -const defaultMinBreadcrumbWidth = 150; -const ellipsisWidth = 50; +const MIN_BREADCRUMB_WIDTH = 70; +const ELLIPSIS_WIDTH = 50; export const getItemsDisplayProperties = (itemsWidths: Array, navWidth: number | null) => { - const minBreadcrumbWidth = optimizeMinWidth(itemsWidths, navWidth); - const shrinkFactors = itemsWidths.map(width => (width <= minBreadcrumbWidth ? 0 : Math.round(width))); - const minWidths = itemsWidths.map(width => (width <= minBreadcrumbWidth ? 0 : minBreadcrumbWidth)); - const collapsedWidths = itemsWidths.map(width => Math.min(width, minBreadcrumbWidth)); + const widthsWithFinalCollapsed = [...itemsWidths]; + widthsWithFinalCollapsed[itemsWidths.length - 1] = Math.min( + itemsWidths[itemsWidths.length - 1], + MIN_BREADCRUMB_WIDTH + ); return { - shrinkFactors, - minWidths, - collapsed: computeNumberOfCollapsedItems(collapsedWidths, navWidth), + collapsed: computeNumberOfCollapsedItems(widthsWithFinalCollapsed, navWidth), }; }; -const computeNumberOfCollapsedItems = (collapsedWidths: Array, navWidth: number | null): number => { - if (!navWidth) { +const computeNumberOfCollapsedItems = (itemWidths: Array, navWidth: number | null): number => { + if (typeof navWidth !== 'number') { return 0; } - let collapsed = 0; - const itemsCount = collapsedWidths.length; - if (itemsCount > 2) { - collapsed = itemsCount - 2; - let remainingWidth = navWidth - collapsedWidths[0] - collapsedWidths[itemsCount - 1] - ellipsisWidth; - let j = 1; - while (remainingWidth > 0 && j < itemsCount - 1) { - remainingWidth -= collapsedWidths[itemsCount - 1 - j]; - j++; - if (remainingWidth >= 0) { - collapsed--; - } + let usedWidth = itemWidths.reduce((acc, width) => acc + width, 0); + let collapsedItems = 0; + while (collapsedItems < itemWidths.length - 1) { + if (usedWidth <= navWidth) { + break; } - } - return collapsed; -}; - -const optimizeMinWidth = (itemsWidths: Array, navWidth: number | null): number => { - const collapsedWidths = itemsWidths.map(width => Math.min(width, defaultMinBreadcrumbWidth)); - if (!navWidth) { - return defaultMinBreadcrumbWidth; - } - const itemsCount = collapsedWidths.length; - if (itemsCount > 2) { - const minCollapsedWidth = collapsedWidths[0] + ellipsisWidth + collapsedWidths[collapsedWidths.length - 1]; - if (minCollapsedWidth > navWidth) { - return (navWidth - ellipsisWidth) / 2; + collapsedItems += 1; + usedWidth = usedWidth - itemWidths[collapsedItems]; + if (collapsedItems === 1) { + usedWidth += ELLIPSIS_WIDTH; } } - if (itemsCount === 2) { - const minCollapsedWidth = collapsedWidths[0] + collapsedWidths[1]; - if (minCollapsedWidth > navWidth) { - return navWidth / 2; - } - } - if (itemsCount === 1) { - return Math.min(navWidth, collapsedWidths[0]); - } - return defaultMinBreadcrumbWidth; + return collapsedItems; }; diff --git a/src/button-dropdown/interfaces.ts b/src/button-dropdown/interfaces.ts index 049823a8d1..8e652880fa 100644 --- a/src/button-dropdown/interfaces.ts +++ b/src/button-dropdown/interfaces.ts @@ -256,7 +256,15 @@ export interface ItemProps { linkStyle?: boolean; } -interface InternalItem extends ButtonDropdownProps.Item { +export interface InternalItem extends ButtonDropdownProps.Item { + badge?: boolean; + /** + * Used in breadcrumb-group: indicates that this breadcrumb item is the current page + */ + isCurrentBreadcrumb?: boolean; +} + +export interface InternalCheckboxItem extends ButtonDropdownProps.CheckboxItem { badge?: boolean; } @@ -266,7 +274,7 @@ interface InternalItemGroup extends Omit type InternalItems = ReadonlyArray; -export type InternalItemOrGroup = InternalItem | ButtonDropdownProps.CheckboxItem | InternalItemGroup; +export type InternalItemOrGroup = InternalItem | InternalCheckboxItem | InternalItemGroup; export interface InternalButtonDropdownProps extends Omit, @@ -303,6 +311,12 @@ export interface InternalButtonDropdownProps */ linkStyle?: boolean; + /** + * Determines whether the dropdown should take up the full available width. + * Used in Breadcrumb group component for collapsed breadcrumbs + */ + fullWidth?: boolean; + analyticsMetadataTransformer?: (input: GeneratedAnalyticsMetadataFragment) => GeneratedAnalyticsMetadataFragment; } diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index bf6f20bd41..4ff803cf39 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -18,6 +18,7 @@ import { useMobile } from '../internal/hooks/use-mobile'; import { useUniqueId } from '../internal/hooks/use-unique-id'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode/index.js'; import { isDevelopment } from '../internal/is-development'; +import { spinWhenOpen } from '../internal/styles/motion/utils'; import { checkSafeUrl } from '../internal/utils/check-safe-url'; import { GeneratedAnalyticsMetadataButtonDropdownExpand } from './analytics-metadata/interfaces.js'; import { ButtonDropdownProps, InternalButtonDropdownProps } from './interfaces'; @@ -52,6 +53,7 @@ const InternalButtonDropdown = React.forwardRef( __internalRootRef, analyticsMetadataTransformer, linkStyle, + fullWidth, ...props }: InternalButtonDropdownProps, ref: React.Ref @@ -129,7 +131,7 @@ const InternalButtonDropdown = React.forwardRef( : { iconName: 'caret-down-filled', iconAlign: 'right', - __iconClass: canBeOpened && isOpen ? styles['rotate-up'] : styles['rotate-down'], + __iconClass: spinWhenOpen(styles, 'rotate', canBeOpened && isOpen), }; const baseTriggerProps: InternalButtonProps = { @@ -306,7 +308,12 @@ const InternalButtonDropdown = React.forwardRef( onKeyUp={onKeyUp} onMouseDown={handleMouseEvent} onMouseMove={handleMouseEvent} - className={clsx(styles['button-dropdown'], styles[`variant-${variant}`], baseProps.className)} + className={clsx( + styles['button-dropdown'], + styles[`variant-${variant}`], + fullWidth && styles['full-width'], + baseProps.className + )} aria-owns={expandToViewport && isOpen ? dropdownId : undefined} ref={__internalRootRef} > diff --git a/src/button-dropdown/item-element/index.tsx b/src/button-dropdown/item-element/index.tsx index 011600175c..464a9aadf1 100644 --- a/src/button-dropdown/item-element/index.tsx +++ b/src/button-dropdown/item-element/index.tsx @@ -12,8 +12,7 @@ import InternalIcon, { InternalIconProps } from '../../icon/internal'; import { useDropdownContext } from '../../internal/components/dropdown/context'; import useHiddenDescription from '../../internal/hooks/use-hidden-description'; import { GeneratedAnalyticsMetadataButtonDropdownClick } from '../analytics-metadata/interfaces'; -import { ItemProps, LinkItem } from '../interfaces'; -import { ButtonDropdownProps } from '../interfaces'; +import { InternalCheckboxItem, InternalItem, ItemProps, LinkItem } from '../interfaces'; import Tooltip from '../tooltip'; import { getMenuItemCheckboxProps, getMenuItemProps } from '../utils/menu-item'; import { isCheckboxItem, isLinkItem } from '../utils/utils'; @@ -89,16 +88,8 @@ const ItemElement = ({ ); }; -type InternalItemProps = ButtonDropdownProps.Item & { - badge?: boolean; -}; - -type InternalCheckboxItemProps = ButtonDropdownProps.CheckboxItem & { - badge?: boolean; -}; - interface MenuItemProps { - item: InternalItemProps | InternalCheckboxItemProps; + item: InternalItem | InternalCheckboxItem; disabled: boolean; highlighted: boolean; linkStyle?: boolean; @@ -107,6 +98,7 @@ interface MenuItemProps { function MenuItem({ item, disabled, highlighted, linkStyle }: MenuItemProps) { const menuItemRef = useRef<(HTMLSpanElement & HTMLAnchorElement) | null>(null); const isCheckbox = isCheckboxItem(item); + const isCurrentBreadcrumb = !isCheckbox && item.isCurrentBreadcrumb; useEffect(() => { if (highlighted && menuItemRef.current) { @@ -118,7 +110,13 @@ function MenuItem({ item, disabled, highlighted, linkStyle }: MenuItemProps) { const { targetProps, descriptionEl } = useHiddenDescription(item.disabledReason); const menuItemProps: React.HTMLAttributes = { 'aria-label': item.ariaLabel, - className: clsx(styles['menu-item'], analyticsLabels['menu-item'], linkStyle && styles['link-style']), + className: clsx( + styles['menu-item'], + analyticsLabels['menu-item'], + linkStyle && styles['link-style'], + isCurrentBreadcrumb && styles['current-breadcrumb'] + ), + 'aria-current': isCurrentBreadcrumb, lang: item.lang, ref: menuItemRef, // We are using the roving tabindex technique to manage the focus state of the dropdown. @@ -156,13 +154,7 @@ function MenuItem({ item, disabled, highlighted, linkStyle }: MenuItemProps) { ); } -const MenuItemContent = ({ - item, - disabled, -}: { - item: InternalItemProps | InternalCheckboxItemProps; - disabled: boolean; -}) => { +const MenuItemContent = ({ item, disabled }: { item: InternalItem | InternalCheckboxItem; disabled: boolean }) => { const hasIcon = !!(item.iconName || item.iconUrl || item.iconSvg); const hasExternal = isLinkItem(item) && item.external; const isCheckbox = isCheckboxItem(item); diff --git a/src/button-dropdown/item-element/styles.scss b/src/button-dropdown/item-element/styles.scss index 6cbd3e9558..e2579d6864 100644 --- a/src/button-dropdown/item-element/styles.scss +++ b/src/button-dropdown/item-element/styles.scss @@ -64,6 +64,12 @@ &.link-style { padding-block-end: calc(#{styles.$option-padding-vertical} + #{awsui.$space-xxxs}); @include styles.link-inline('body-m'); + &.current-breadcrumb { + @include styles.font-button; + color: awsui.$color-text-breadcrumb-current; + font-weight: styles.$font-weight-bold; + text-decoration: none; + } } &:focus { diff --git a/src/button-dropdown/styles.scss b/src/button-dropdown/styles.scss index 8de2c0b1b8..fc9a4c4662 100644 --- a/src/button-dropdown/styles.scss +++ b/src/button-dropdown/styles.scss @@ -10,6 +10,10 @@ $dropdown-trigger-icon-offset: 2px; .button-dropdown { display: inline-block; + &.full-width { + display: block; + max-inline-size: 100%; + } } .items-list-container { @@ -24,18 +28,8 @@ $dropdown-trigger-icon-offset: 2px; } } -.rotate-up { - transform: rotate(-180deg); - @include styles.with-motion { - transition: transform awsui.$motion-duration-rotate-180 awsui.$motion-easing-rotate-180; - } -} - -.rotate-down { - transform: rotate(0deg); - @include styles.with-motion { - transition: transform awsui.$motion-duration-rotate-180 awsui.$motion-easing-rotate-180; - } +.rotate { + @include styles.spin-180-when-open; } .header { diff --git a/src/internal/components/menu-dropdown/index.tsx b/src/internal/components/menu-dropdown/index.tsx index 1c6e020cfe..cae25ab4d5 100644 --- a/src/internal/components/menu-dropdown/index.tsx +++ b/src/internal/components/menu-dropdown/index.tsx @@ -7,6 +7,7 @@ import { CustomTriggerProps } from '../../../button-dropdown/interfaces'; import InternalButtonDropdown from '../../../button-dropdown/internal'; import InternalIcon from '../../../icon/internal'; import { getBaseProps } from '../../base-component'; +import { spinWhenOpen } from '../../styles/motion/utils'; import { applyDisplayName } from '../../utils/apply-display-name'; import { ButtonTriggerProps, MenuDropdownProps } from './interfaces'; @@ -63,10 +64,7 @@ export const ButtonTrigger = React.forwardRef( )} {children && {children}} {children && ( - + )} ); diff --git a/src/internal/styles/motion/animations.scss b/src/internal/styles/motion/animations.scss index 2683c0c8ff..caa4ce6468 100644 --- a/src/internal/styles/motion/animations.scss +++ b/src/internal/styles/motion/animations.scss @@ -3,6 +3,7 @@ SPDX-License-Identifier: Apache-2.0 */ +@use './mixins' as mixins; @use '../../../internal/styles/tokens' as awsui; @mixin animation-fade-in { @@ -80,3 +81,13 @@ } } } + +@mixin spin-180-when-open { + transform: rotate(0deg); + @include mixins.with-motion { + transition: transform awsui.$motion-duration-rotate-180 awsui.$motion-easing-rotate-180; + } + &-open { + transform: rotate(-180deg); + } +} diff --git a/src/internal/styles/motion/utils.ts b/src/internal/styles/motion/utils.ts new file mode 100644 index 0000000000..65498ff06a --- /dev/null +++ b/src/internal/styles/motion/utils.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import clsx from 'clsx'; + +export const spinWhenOpen = (styles: Record, className: string, open: boolean) => + clsx(styles[className], open && styles[`${className}-open`]); diff --git a/src/top-navigation/1.0-beta/styles.scss b/src/top-navigation/1.0-beta/styles.scss index d6bd3327f5..c615e005db 100644 --- a/src/top-navigation/1.0-beta/styles.scss +++ b/src/top-navigation/1.0-beta/styles.scss @@ -217,17 +217,3 @@ .trigger { /*used in test-utils*/ } - -.rotate-up { - transform: rotate(-180deg); - @include styles.with-motion { - transition: transform awsui.$motion-duration-rotate-180 awsui.$motion-easing-rotate-180; - } -} - -.rotate-down { - transform: rotate(0deg); - @include styles.with-motion { - transition: transform awsui.$motion-duration-rotate-180 awsui.$motion-easing-rotate-180; - } -} diff --git a/src/top-navigation/motion.scss b/src/top-navigation/motion.scss deleted file mode 100644 index f89afa34f6..0000000000 --- a/src/top-navigation/motion.scss +++ /dev/null @@ -1,13 +0,0 @@ -/* - Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - SPDX-License-Identifier: Apache-2.0 -*/ - -@use '../internal/styles' as styles; -@use '../internal/styles/tokens' as tokens; - -.icon { - @include styles.with-motion { - transition: transform tokens.$motion-duration-rotate-90 tokens.$motion-easing-rotate-90; - } -} diff --git a/src/top-navigation/parts/overflow-menu/menu-item.tsx b/src/top-navigation/parts/overflow-menu/menu-item.tsx index 783053c6cb..b53b9d2e7f 100644 --- a/src/top-navigation/parts/overflow-menu/menu-item.tsx +++ b/src/top-navigation/parts/overflow-menu/menu-item.tsx @@ -8,6 +8,7 @@ import { isLinkItem } from '../../../button-dropdown/utils/utils'; import InternalIcon from '../../../icon/internal'; import { fireCancelableEvent, isPlainLeftClick } from '../../../internal/events'; import { useUniqueId } from '../../../internal/hooks/use-unique-id'; +import { spinWhenOpen } from '../../../internal/styles/motion/utils'; import { LinkProps } from '../../../link/interfaces'; import { TopNavigationProps } from '../../interfaces'; import { useNavigate } from './router'; @@ -140,7 +141,7 @@ const ExpandableItem: React.FC< > + } diff --git a/src/top-navigation/styles.scss b/src/top-navigation/styles.scss index 22cffaef08..99740f00f5 100644 --- a/src/top-navigation/styles.scss +++ b/src/top-navigation/styles.scss @@ -7,8 +7,6 @@ @use '../internal/styles/tokens' as awsui; @use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; -@use './motion'; - .top-navigation { @include styles.styles-reset; background: awsui.$color-background-container-content; @@ -347,9 +345,5 @@ } .icon { - transform: rotate(-180deg); - - &.expanded { - transform: rotate(0deg); - } + @include styles.spin-180-when-open; }