Skip to content

Commit

Permalink
feat: Show popover on truncated breadcrumb text (cloudscape-design#527)
Browse files Browse the repository at this point in the history
  • Loading branch information
YueyingLu authored Nov 29, 2022
1 parent dccc1f4 commit cc4db96
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 31 deletions.
48 changes: 37 additions & 11 deletions pages/breadcrumb-group/events.page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';
import { SpaceBetween } from '~components';

import BreadcrumbGroup, { BreadcrumbGroupProps } from '~components/breadcrumb-group';
import ScreenshotArea from '../utils/screenshot-area';
const items = ['First', 'Second', 'Third', 'Fourth', 'Fifth', 'Sixth'];
const items = [
'First that is very very very very very very long long long text',
'Second',
'Third',
'Fourth',
'Fifth',
'Sixth that is very very very very very very long long long text',
];

const shortItems = ['1', '2', '3', '4'];

export default function ButtonDropdownPage() {
const [onFollowMessage, setOnFollowMessage] = useState('');
Expand All @@ -20,16 +30,32 @@ export default function ButtonDropdownPage() {
<ScreenshotArea disableAnimations={true}>
<article>
<h1>BreadcrumbGroup variations</h1>
<BreadcrumbGroup
ariaLabel="Navigation"
expandAriaLabel="Show path"
items={items.map(text => ({ text, href: `#` }))}
onFollow={onFollowCallback}
onClick={onClickCallback}
/>
<div />
<div id="onFollowMessage">{onFollowMessage}</div>
<div id="onClickMessage">{onClickMessage}</div>
<SpaceBetween size="xxl">
<div>
<button type="button" id="focus-target-long-text">
focus long text
</button>
<BreadcrumbGroup
ariaLabel="Navigation long text"
expandAriaLabel="Show path for long text"
items={items.map(text => ({ text, href: `#` }))}
onFollow={onFollowCallback}
onClick={onClickCallback}
/>
<div id="onFollowMessage">{onFollowMessage}</div>
<div id="onClickMessage">{onClickMessage}</div>
</div>
<div>
<button type="button" id="focus-target-short-text">
focus short text
</button>
<BreadcrumbGroup
ariaLabel="Navigation short text"
expandAriaLabel="Show path for short text"
items={shortItems.map(text => ({ text, href: `#` }))}
/>
</div>
</SpaceBetween>
</article>
</ScreenshotArea>
);
Expand Down
34 changes: 34 additions & 0 deletions src/breadcrumb-group/__integ__/breadcrumb-group.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';
import createWrapper from '../../../lib/components/test-utils/selectors';
import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
import styles from '../../../lib/components/breadcrumb-group/item/styles.selectors.js';

const breadcrumbGroupWrapper = createWrapper().findBreadcrumbGroup();
const dropdownWrapper = breadcrumbGroupWrapper.findDropdown();
Expand Down Expand Up @@ -73,4 +74,37 @@ describe('BreadcrumbGroup', () => {
await expect(page.getText('#onClickMessage')).resolves.toEqual('OnClick: Second item was selected');
})
);

test(
'Item popover should not show on large screen',
setupTest(async page => {
await page.setWindowSize({ width: 1200, height: 800 });
await page.click('#focus-target-long-text');
await page.keys('Tab');
await expect(page.isExisting(createWrapper().find(`.${styles['item-popover']}`).toSelector())).resolves.toBe(
false
);
})
);

test(
'Item popover should show on small screen when text get truncated, and should close pressing Escape',
setupTest(async page => {
await page.setMobileViewport();
await page.click('#focus-target-long-text');
await page.keys('Tab');
await expect(page.isExisting(createWrapper().find(`.${styles['item-popover']}`).toSelector())).resolves.toBe(
true
);
await page.keys('Escape');
await expect(page.isExisting(createWrapper().find(`.${styles['item-popover']}`).toSelector())).resolves.toBe(
false
);
await page.click('#focus-target-short-text');
await page.keys('Tab');
await expect(page.isExisting(createWrapper().find(`.${styles['item-popover']}`).toSelector())).resolves.toBe(
false
);
})
);
});
1 change: 1 addition & 0 deletions src/breadcrumb-group/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export namespace BreadcrumbGroupProps {

export interface BreadcrumbItemProps<T extends BreadcrumbGroupProps.Item> {
item: T;
isDisplayed: boolean;
isLast?: boolean;
isCompressed?: boolean;
onClick?: CancelableEventHandler<BreadcrumbGroupProps.ClickDetail<T>>;
Expand Down
1 change: 1 addition & 0 deletions src/breadcrumb-group/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export default function InternalBreadcrumbGroup<T extends BreadcrumbGroupProps.I
onFollow={onFollow}
isCompressed={isMobile}
isLast={index === items.length - 1}
isDisplayed={!isMobile || index === items.length - 1 || index === 0}
/>
</li>
);
Expand Down
145 changes: 126 additions & 19 deletions src/breadcrumb-group/item/item.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,117 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { BreadcrumbGroupProps, BreadcrumbItemProps } from '../interfaces';
import InternalIcon from '../../icon/internal';
import styles from './styles.css.js';
import clsx from 'clsx';
import useFocusVisible from '../../internal/hooks/focus-visible';
import { fireCancelableEvent, isPlainLeftClick } from '../../internal/events';
import { getEventDetail } from '../internal';
import { Transition } from '../../internal/components/transition';
import PopoverContainer from '../../popover/container';
import PopoverBody from '../../popover/body';
import Portal from '../../internal/components/portal';
import popoverStyles from '../../popover/styles.css.js';

type BreadcrumbItemWithPopoverProps<T extends BreadcrumbGroupProps.Item> =
React.AnchorHTMLAttributes<HTMLAnchorElement> & {
item: T;
};

const BreadcrumbItemWithPopover = <T extends BreadcrumbGroupProps.Item>({
item,
...anchorAttributes
}: BreadcrumbItemWithPopoverProps<T>) => {
const focusVisible = useFocusVisible();
const [showPopover, setShowPopover] = useState(false);
const textRef = useRef<HTMLElement>(null);
const virtualTextRef = useRef<HTMLElement>(null);

const isTruncated = (textRef: React.RefObject<HTMLElement>, virtualTextRef: React.RefObject<HTMLElement>) => {
if (!textRef || !virtualTextRef || !textRef.current || !virtualTextRef.current) {
return false;
}
const virtualTextWidth = virtualTextRef.current.getBoundingClientRect().width;
const textWidth = textRef.current.getBoundingClientRect().width;
if (virtualTextWidth > textWidth) {
return true;
}
return false;
};

const popoverContent = (
<Portal>
<div className={styles['item-popover']}>
<Transition in={true}>
{() => (
<PopoverContainer
trackRef={textRef}
size="small"
fixedWidth={false}
position="bottom"
arrow={position => (
<div className={clsx(popoverStyles.arrow, popoverStyles[`arrow-position-${position}`])}>
<div className={popoverStyles['arrow-outer']} />
<div className={popoverStyles['arrow-inner']} />
</div>
)}
>
<PopoverBody dismissButton={false} dismissAriaLabel={undefined} onDismiss={() => {}} header={undefined}>
{item.text}
</PopoverBody>
</PopoverContainer>
)}
</Transition>
</div>
</Portal>
);

useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setShowPopover(false);
}
};
if (showPopover) {
document.addEventListener('keydown', onKeyDown);
}
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, [showPopover]);

return (
<>
<a
{...focusVisible}
{...anchorAttributes}
onFocus={() => {
isTruncated(textRef, virtualTextRef) && setShowPopover(true);
}}
onBlur={() => setShowPopover(false)}
onMouseEnter={() => {
isTruncated(textRef, virtualTextRef) && setShowPopover(true);
}}
onMouseLeave={() => setShowPopover(false)}
>
<span className={styles.text} ref={textRef}>
{item.text}
</span>
<span className={styles['virtual-item']} ref={virtualTextRef}>
{item.text}
</span>
</a>
{showPopover && popoverContent}
</>
);
};

export function BreadcrumbItem<T extends BreadcrumbGroupProps.Item>({
item,
onClick,
onFollow,
isDisplayed,
isLast = false,
isCompressed = false,
}: BreadcrumbItemProps<T>) {
Expand All @@ -24,24 +123,32 @@ export function BreadcrumbItem<T extends BreadcrumbGroupProps.Item>({
}
fireCancelableEvent(onClick, getEventDetail(item), event);
};

const anchorAttributes: React.AnchorHTMLAttributes<HTMLAnchorElement> = {
href: isLast ? undefined : item.href || '#',
className: clsx(styles.anchor, { [styles.compressed]: isCompressed }),
'aria-current': isLast ? 'page' : undefined, // Active breadcrumb item is implemented according to WAI-ARIA 1.1
'aria-disabled': isLast && 'true',
onClick: isLast ? preventDefault : onClickHandler,
tabIndex: isLast ? 0 : undefined, // tabIndex is added to the last crumb to keep it in the index without an href
};

return (
<div className={clsx(styles.breadcrumb, isLast && styles.last)}>
<a
{...focusVisible}
href={isLast ? undefined : item.href || '#'}
className={clsx(styles.anchor, { [styles.compressed]: isCompressed })}
aria-current={isLast ? 'page' : undefined} // Active breadcrumb item is implemented according to WAI-ARIA 1.1
aria-disabled={isLast && 'true'}
onClick={isLast ? preventDefault : onClickHandler}
tabIndex={isLast ? 0 : undefined} // tabIndex is added to the last crumb to keep it in the index without an href
>
<span className={styles.text}>{item.text}</span>
</a>
{!isLast ? (
<span className={styles.icon}>
<InternalIcon name="angle-right" />
</span>
) : null}
</div>
<>
<div className={clsx(styles.breadcrumb, isLast && styles.last)}>
{isDisplayed && isCompressed ? (
<BreadcrumbItemWithPopover item={item} {...anchorAttributes} />
) : (
<a {...focusVisible} {...anchorAttributes}>
<span className={styles.text}>{item.text}</span>
</a>
)}
{!isLast ? (
<span className={styles.icon}>
<InternalIcon name="angle-right" />
</span>
) : null}
</div>
</>
);
}
8 changes: 7 additions & 1 deletion src/breadcrumb-group/item/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
font-weight: styles.$font-weight-bold;
text-decoration: none;
cursor: default;
pointer-events: none;
}
}
}
Expand All @@ -50,3 +49,10 @@
display: block;
}
}
.virtual-item {
@include styles.awsui-util-hide;
visibility: hidden;
}
.item-popover {
/* used in tests */
}

0 comments on commit cc4db96

Please sign in to comment.