Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Mobile breadcrumbs #2843

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
4 changes: 4 additions & 0 deletions pages/app-layout/utils/content-blocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export function Breadcrumbs() {
items={[
{ text: 'Home', href: '#' },
{ text: 'Service', href: '#' },
{
text: 'Service sdfjlsdk sdjfklds sdjfklds sdjfkldssdjfkldssdjfklds',
href: '#',
},
]}
/>
);
Expand Down
28 changes: 28 additions & 0 deletions pages/breadcrumb-group/mobile.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';

import BreadcrumbGroup from '~components/breadcrumb-group';

import ScreenshotArea from '../utils/screenshot-area';

export default function ButtonDropdownPage() {
return (
<ScreenshotArea disableAnimations={true}>
<article>
<h1>BreadcrumbGroup mobile</h1>
<BreadcrumbGroup
ariaLabel={'Navigation'}
expandAriaLabel="Show path"
items={[
{ text: 'Service home', href: '#' },
{ text: 'Another page', href: '#' },
{ text: 'A long page title for a media folder', href: '#' },
{ text: 'Resource bucket 123456789', href: '#' },
{ text: 'CODETEST-Cluster-CodeTest Entities-32697437621-ae 2dd 98c1cfb97f2a1bb842671fba9fb', href: '#' },
]}
/>
</article>
</ScreenshotArea>
);
}
41 changes: 17 additions & 24 deletions src/breadcrumb-group/__integ__/breadcrumb-group.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,26 +74,12 @@ const setupTest = (
};
describe('BreadcrumbGroup', () => {
test(
'Has proper number of items in the dropdown',
'Has proper number of items in the mobile dropdown',
setupTest(async page => {
await page.setWindowSize({ width: 645, height: 800 });
await page.openDropdown();
expect(await page.getElementsCount(dropdownItemsSelector)).toBe(1);
expect(await page.getElementsCount(dropdownItemsSelector)).toBe(6);
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);
})
);
test(
Expand Down Expand Up @@ -135,8 +121,12 @@ describe('BreadcrumbGroup', () => {
await expect(page.getText('#onFollowMessage')).resolves.toEqual('');
await expect(page.getText('#onClickMessage')).resolves.toEqual('');
await page.clickItem(1);
await expect(page.getText('#onFollowMessage')).resolves.toEqual('OnFollow: Second item was selected');
await expect(page.getText('#onClickMessage')).resolves.toEqual('OnClick: Second item was selected');
await expect(page.getText('#onFollowMessage')).resolves.toEqual(
'OnFollow: First that is very very very very very very long long long text item was selected'
);
await expect(page.getText('#onClickMessage')).resolves.toEqual(
'OnClick: First that is very very very very very very long long long text item was selected'
);
})
);

Expand All @@ -151,7 +141,7 @@ describe('BreadcrumbGroup', () => {
await page.keys('Tab');
await expect(page.isTooltipDisplayed()).resolves.toBe(false);
},
{ width: 100, height: 800 }
{ width: 700, height: 800 }
)
);

Expand Down Expand Up @@ -185,7 +175,7 @@ describe('BreadcrumbGroup', () => {
'should be displayed for truncated items after collapsing items into dropdown',
setupTest(
async page => {
await page.setMobileViewport();
await page.setWindowSize({ width: 700, height: 800 });
await page.click('#focus-target-long-text');
await page.keys('Tab');
await expect(page.isTooltipDisplayed()).resolves.toBe(true);
Expand Down Expand Up @@ -213,7 +203,7 @@ describe('BreadcrumbGroup', () => {
test(
'Item popover should close after pressing Escape',
setupTest(async page => {
await page.setMobileViewport();
await page.setWindowSize({ width: 700, height: 800 });
await page.click('#focus-target-long-text');
await page.keys('Tab');
await expect(page.isTooltipDisplayed()).resolves.toBe(true);
Expand All @@ -226,7 +216,7 @@ describe('BreadcrumbGroup', () => {
test(
'Attaches funnel name attribute to last breadcrumb item',
setupTest(async (page, browser) => {
await page.setMobileViewport();
await page.setWindowSize({ width: 700, 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');
})
Expand All @@ -252,11 +242,14 @@ describe('BreadcrumbGroup', () => {
test(
'Last item is focusable when truncated',
setupTest(async page => {
await page.setMobileViewport();
await page.setWindowSize({ width: 700, height: 800 });
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);
Expand All @@ -267,7 +260,7 @@ describe('BreadcrumbGroup', () => {
test(
'Displays only one tooltip at the time',
setupTest(async page => {
await page.setMobileViewport();
await page.setWindowSize({ width: 700, height: 800 });
await page.click('#focus-target-long-text');
await page.keys('Tab');
await expect(page.countTooltips()).resolves.toBe(1);
Expand Down
96 changes: 96 additions & 0 deletions src/breadcrumb-group/__tests__/mobile-breadcrumbs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { render } from '@testing-library/react';

import BreadcrumbGroup, { BreadcrumbGroupProps } from '../../../lib/components/breadcrumb-group';
import createWrapper from '../../../lib/components/test-utils/dom';

import dropdownItemStyles from '../../../lib/components/button-dropdown/item-element/styles.css.js';

const defaultProps = {
items: [
{ text: 'Service home', href: '#' },
{ text: 'Another page', href: '#' },
{ text: 'A long page title for a media folder', href: '#' },
{ text: 'Resource bucket 123456789', href: '#' },
],
};

const renderBreadcrumbGroup = (props: BreadcrumbGroupProps) => {
const renderResult = render(<BreadcrumbGroup {...props} />);
return { wrapper: createWrapper(renderResult.container).findBreadcrumbGroup()!, ...renderResult };
};

jest.mock('../../../lib/components/internal/hooks/use-mobile', () => ({
...jest.requireActual('../../../lib/components/internal/hooks/use-mobile'),
useMobile: jest.fn().mockReturnValue(true),
}));

describe('Mobile BreadcrumbGroup Component', () => {
test('renders correctly (dropdown closed)', () => {
const { wrapper } = renderBreadcrumbGroup(defaultProps);

const button = wrapper.findDropdown()!.findNativeButton().getElement();

expect(button).toHaveAttribute('aria-expanded', 'false');
expect(button).toHaveAttribute('aria-haspopup', 'true');
expect(button).toHaveTextContent('Resource bucket 123456789');
});

test('renders correctly (dropdown open)', () => {
const { wrapper } = renderBreadcrumbGroup(defaultProps);

const button = wrapper.findDropdown()!.findNativeButton();
button.click();

expect(button.getElement()).toHaveAttribute('aria-expanded', 'true');
expect(wrapper.findDropdown()!.findItems()).toHaveLength(defaultProps.items.length);
for (let i = 0; i < defaultProps.items.length - 1; i++) {
expect(
wrapper.findDropdown()!.findItems()[i].find(`.${dropdownItemStyles['menu-item']}`)!.getElement()
).toHaveAttribute('href', defaultProps.items[i].href);
}
expect(
wrapper
.findDropdown()!
.findItems()
[defaultProps.items.length - 1].find(`.${dropdownItemStyles['menu-item']}`)!
.getElement()
).not.toHaveAttribute('href');
});

test('with zero items', () => {
const { wrapper } = renderBreadcrumbGroup({ items: [] });

expect(wrapper.findDropdown()).toBeNull();
});

test('fires a click and follow events when the breadcrumb item is clicked', () => {
const onClickSpy = jest.fn();
const onFollowSpy = jest.fn();
const { wrapper } = renderBreadcrumbGroup({
...defaultProps,
onClick: event => {
event.preventDefault();
onClickSpy(event.detail);
},
onFollow: event => onFollowSpy(event.detail),
});

const button = wrapper.findDropdown()!.findNativeButton();
button.click();
wrapper.findDropdown()!.findItems()[0].click();

expect(onClickSpy).toHaveBeenCalledWith({
item: defaultProps.items[0],
text: defaultProps.items[0].text,
href: defaultProps.items[0].href,
});
expect(onFollowSpy).toHaveBeenCalledWith({
item: defaultProps.items[0],
text: defaultProps.items[0].text,
href: defaultProps.items[0].href,
});
});
});
93 changes: 88 additions & 5 deletions src/breadcrumb-group/implementation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,19 @@ import InternalIcon from '../icon/internal';
import { getBaseProps } from '../internal/base-component';
import { fireCancelableEvent } from '../internal/events';
import { useMergeRefs } from '../internal/hooks/use-merge-refs';
import { useMobile } from '../internal/hooks/use-mobile';
import { checkSafeUrl } from '../internal/utils/check-safe-url';
import { createWidgetizedComponent } from '../internal/widgets';
import {
GeneratedAnalyticsMetadataBreadcrumbGroupClick,
GeneratedAnalyticsMetadataBreadcrumbGroupComponent,
} from './analytics-metadata/interfaces';
import { BreadcrumbGroupProps, EllipsisDropdownProps, InternalBreadcrumbGroupProps } from './interfaces';
import {
BreadcrumbGroupProps,
EllipsisDropdownProps,
InternalBreadcrumbGroupProps,
MobileDropdownProps,
} from './interfaces';
import { BreadcrumbItem } from './item/item';
import { getEventDetail, getItemsDisplayProperties } from './utils';

Expand Down Expand Up @@ -96,6 +102,63 @@ const EllipsisDropdown = ({
);
};

const MobileDropdown = ({
ariaLabel,
dropdownItems,
onDropdownItemClick,
onDropdownItemFollow,
}: MobileDropdownProps) => {
const i18n = useInternalI18n('breadcrumb-group');

if (dropdownItems.length === 0) {
return null;
}

return (
<InternalButtonDropdown
variant="mobile-breadcrumb"
preferCenter={true}
ariaLabel={i18n('expandAriaLabel', ariaLabel) ?? DEFAULT_EXPAND_ARIA_LABEL}
items={dropdownItems}
onItemClick={onDropdownItemClick}
onItemFollow={onDropdownItemFollow}
customTriggerBuilder={props => (
<InternalButton
fullWidth={true}
ref={props.triggerRef}
className={props.testUtilsClass}
disabled={props.disabled}
onClick={event => {
event.preventDefault();
props.onClick();
}}
ariaExpanded={props.isOpen}
aria-haspopup={true}
ariaLabel={ariaLabel}
variant="breadcrumb-group"
formAction="none"
>
<div className={styles['mobile-dropdown-trigger-content']}>
<InternalIcon name={props.isOpen ? 'caret-down-filled' : 'caret-right-filled'} />
<span className={styles['mobile-dropdown-trigger-text']}>
{dropdownItems[dropdownItems.length - 1].text}
</span>
</div>
</InternalButton>
)}
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;
}}
/>
);
};

interface ItemsRefsType {
ghost: Record<string, HTMLLIElement>;
real: Record<string, HTMLLIElement>;
Expand Down Expand Up @@ -129,6 +192,7 @@ export function BreadcrumbGroupImplementation<T extends BreadcrumbGroupProps.Ite
const baseProps = getBaseProps(props);
const [navWidth, navRef] = useContainerQuery<number>(rect => rect.borderBoxWidth);
const mergedRef = useMergeRefs(navRef, __internalRootRef);
const isMobile = useMobile();

const itemsRefs = useRef<ItemsRefsType>({ ghost: {}, real: {} });
const setBreadcrumb = (type: keyof ItemsRefsType, index: string, node: null | HTMLLIElement) => {
Expand Down Expand Up @@ -255,10 +319,29 @@ export function BreadcrumbGroupImplementation<T extends BreadcrumbGroupProps.Ite
}
: {})}
>
<ol className={styles['breadcrumb-group-list']}>{breadcrumbItems}</ol>
<ol className={clsx(styles['breadcrumb-group-list'], styles.ghost)} aria-hidden={true} tabIndex={-1}>
{hiddenBreadcrumbItems}
</ol>
{isMobile ? (
<MobileDropdown
ariaLabel={expandAriaLabel}
dropdownItems={items.map((item: BreadcrumbGroupProps.Item, index: number) => {
const isLast = index === items.length - 1;
return {
id: index.toString(),
text: item.text,
href: isLast ? undefined : item.href || '#',
linkStyle: !isLast,
};
})}
onDropdownItemClick={e => fireCancelableEvent(onClick, getEventDetail(getEventItem(e)), e)}
onDropdownItemFollow={e => fireCancelableEvent(onFollow, getEventDetail(getEventItem(e)), e)}
/>
) : (
<>
<ol className={styles['breadcrumb-group-list']}>{breadcrumbItems}</ol>
<ol className={clsx(styles['breadcrumb-group-list'], styles.ghost)} aria-hidden={true} tabIndex={-1}>
{hiddenBreadcrumbItems}
</ol>
</>
)}
</nav>
);
}
Expand Down
9 changes: 8 additions & 1 deletion src/breadcrumb-group/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { LinkItem } from '../button-dropdown/interfaces';
import { ButtonDropdownProps, LinkItem } from '../button-dropdown/interfaces';
import { BaseComponentProps } from '../internal/base-component';
import { BaseNavigationDetail, CancelableEventHandler } from '../internal/events';
import { InternalBaseComponentProps } from '../internal/hooks/use-base-component';
Expand Down Expand Up @@ -77,3 +77,10 @@ export interface EllipsisDropdownProps {
onDropdownItemFollow: CancelableEventHandler<{ id: string }>;
visible?: boolean;
}

export interface MobileDropdownProps {
ariaLabel?: BreadcrumbGroupProps['expandAriaLabel'];
dropdownItems: ReadonlyArray<ButtonDropdownProps.Item & { href?: string }>;
onDropdownItemClick: CancelableEventHandler<{ id: string }>;
onDropdownItemFollow: CancelableEventHandler<{ id: string }>;
}
Loading
Loading