Skip to content

Commit

Permalink
feat(breadcrumb): add element customization (#1895)
Browse files Browse the repository at this point in the history
  • Loading branch information
andioneto committed Sep 30, 2021
1 parent 49a0db6 commit 36cc8dc
Show file tree
Hide file tree
Showing 4 changed files with 354 additions and 32 deletions.
6 changes: 6 additions & 0 deletions .changeset/tame-dingos-exercise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@twilio-paste/breadcrumb': minor
'@twilio-paste/core': minor
---

[Breadcrumb] Enable Component to respect element customizations set on the customization provider. Component now enables setting an element name on the underlying HTML element and checks the emotion theme object to determine whether it should merge in custom styles to the ones set by the component author.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import {matchers} from 'jest-emotion';
import {render, screen} from '@testing-library/react';
import {CustomizationProvider} from '@twilio-paste/customization';
// @ts-ignore typescript doesn't like js imports
import axe from '../../../../../.jest/axe-helper';
import {Breadcrumb, BreadcrumbItem} from '../src';
Expand Down Expand Up @@ -28,7 +29,7 @@ describe('Breadcrumb', () => {
expect(renderedList).toHaveStyleRule('display', 'inline-flex');
});

it('should render listitems', () => {
it('should render list items', () => {
render(<BreadcrumbExample />);
const renderedListItem = screen.findByRole('listitem');
expect(renderedListItem).not.toBeNull();
Expand Down Expand Up @@ -66,4 +67,220 @@ describe('Breadcrumb', () => {
expect(results).toHaveNoViolations();
});
});

describe('Customization', () => {
it('should correctly set data attributes when Breadcrumb and BreadcrumbItem are being passed an element prop', () => {
render(
<Breadcrumb element="TEST_PARENT" data-testid="breadcrumb">
<BreadcrumbItem element="TEST" href="#" data-testid="breadcrumb-item-1">
First
</BreadcrumbItem>
<BreadcrumbItem element="TEST" href="#" data-testid="breadcrumb-item-2">
First
</BreadcrumbItem>
</Breadcrumb>
);

expect(screen.getByTestId('breadcrumb').getAttribute('data-paste-element')).toEqual('TEST_PARENT');

const breadcrumbItem1 = screen.getByTestId('breadcrumb-item-1') as HTMLElement;
const breadcrumbItem2 = screen.getByTestId('breadcrumb-item-2') as HTMLElement;

expect(breadcrumbItem1.getAttribute('data-paste-element')).toEqual('TEST_ITEM');
expect(breadcrumbItem2.getAttribute('data-paste-element')).toEqual('TEST_ITEM');

const node1 = breadcrumbItem1.firstChild as HTMLElement;
const node2 = breadcrumbItem2.firstChild as HTMLElement;

expect(node1.getAttribute('data-paste-element')).toEqual('TEST_ANCHOR');
expect(node2.getAttribute('data-paste-element')).toEqual('TEST_ANCHOR');

const separator1 = breadcrumbItem1.lastChild as HTMLElement;
expect(separator1.getAttribute('data-paste-element')).toEqual('TEST_SEPARATOR');
});

it('should correctly set a element data attribute for Breadcrumb and BreadcrumbItem, when only Breadcrumb is passed a valid (non-falsy string) element', () => {
render(
<Breadcrumb element="TEST_PARENT" data-testid="breadcrumb">
<BreadcrumbItem element="" href="#" data-testid="breadcrumb-item-1">
First
</BreadcrumbItem>
<BreadcrumbItem href="#" data-testid="breadcrumb-item-2">
First
</BreadcrumbItem>
</Breadcrumb>
);

expect(screen.getByTestId('breadcrumb').getAttribute('data-paste-element')).toEqual('TEST_PARENT');

const breadcrumbItem1 = screen.getByTestId('breadcrumb-item-1') as HTMLElement;
const breadcrumbItem2 = screen.getByTestId('breadcrumb-item-2') as HTMLElement;

expect(breadcrumbItem1.getAttribute('data-paste-element')).toEqual('TEST_PARENT_ITEM');
expect(breadcrumbItem2.getAttribute('data-paste-element')).toEqual('TEST_PARENT_ITEM');

const node1 = breadcrumbItem1.firstChild as HTMLElement;
const node2 = breadcrumbItem2.firstChild as HTMLElement;

expect(node1.getAttribute('data-paste-element')).toEqual('TEST_PARENT_ANCHOR');
expect(node2.getAttribute('data-paste-element')).toEqual('TEST_PARENT_ANCHOR');

const separator1 = breadcrumbItem1.lastChild as HTMLElement;

expect(separator1.getAttribute('data-paste-element')).toEqual('TEST_PARENT_SEPARATOR');
});

it('should correctly set a element data attribute for Breadcrumb and BreadcrumbItem, when neither Breadcrumb nor BreadcrumbItem are passed a valid (non-falsy string) element', () => {
render(
<Breadcrumb data-testid="breadcrumb">
<BreadcrumbItem element="" href="#" data-testid="breadcrumb-item-1">
First
</BreadcrumbItem>
<BreadcrumbItem href="#" data-testid="breadcrumb-item-2">
First
</BreadcrumbItem>
</Breadcrumb>
);

expect(screen.getByTestId('breadcrumb').getAttribute('data-paste-element')).toEqual('BREADCRUMB');

const breadcrumbItem1 = screen.getByTestId('breadcrumb-item-1') as HTMLElement;
const breadcrumbItem2 = screen.getByTestId('breadcrumb-item-2') as HTMLElement;

expect(breadcrumbItem1.getAttribute('data-paste-element')).toEqual('BREADCRUMB_ITEM');
expect(breadcrumbItem2.getAttribute('data-paste-element')).toEqual('BREADCRUMB_ITEM');

const node1 = breadcrumbItem1.firstChild as HTMLElement;
const node2 = breadcrumbItem2.firstChild as HTMLElement;

expect(node1.getAttribute('data-paste-element')).toEqual('BREADCRUMB_ANCHOR');
expect(node2.getAttribute('data-paste-element')).toEqual('BREADCRUMB_ANCHOR');

const separator1 = breadcrumbItem1.lastChild as HTMLElement;

expect(separator1.getAttribute('data-paste-element')).toEqual('BREADCRUMB_SEPARATOR');
});
});

describe('Custom styles', () => {
it('should add custom styles to Breadcrumb', () => {
render(
<CustomizationProvider
baseTheme="default"
// @ts-expect-error global test variable
theme={TestTheme}
elements={{
BREADCRUMB: {fontVariantNumeric: 'slashed-zero'},
BREADCRUMB_ITEM: {fontWeight: 'fontWeightMedium'},
BREADCRUMB_ANCHOR: {
textDecoration: 'underline wavy',
color: 'colorTextInverseWeaker',
':hover': {color: 'colorLinkStronger'},
},
BREADCRUMB_TEXT: {letterSpacing: '0.25rem'},
BREADCRUMB_SEPARATOR: {
color: 'colorTextBrandHighlight',
},
}}
>
<Breadcrumb data-testid="breadcrumb">
<BreadcrumbItem href="#" data-testid="breadcrumb-item-1">
First
</BreadcrumbItem>
<BreadcrumbItem href="#" data-testid="breadcrumb-item-2">
Second
</BreadcrumbItem>
<BreadcrumbItem data-testid="breadcrumb-item-3">Third</BreadcrumbItem>
</Breadcrumb>
</CustomizationProvider>
);

expect(screen.getByTestId('breadcrumb')).toHaveStyleRule('font-variant-numeric', 'slashed-zero');

const breadcrumbItem1 = screen.getByTestId('breadcrumb-item-1') as HTMLElement;
const breadcrumbItem2 = screen.getByTestId('breadcrumb-item-2') as HTMLElement;
const breadcrumbItem3 = screen.getByTestId('breadcrumb-item-3') as HTMLElement;

expect(breadcrumbItem1).toHaveStyleRule('font-weight', '500');
expect(breadcrumbItem2).toHaveStyleRule('font-weight', '500');
expect(breadcrumbItem3).toHaveStyleRule('font-weight', '500');

const node1 = breadcrumbItem1.firstChild as HTMLElement;
const separator1 = breadcrumbItem1.lastChild as HTMLElement;
expect(node1).toHaveStyleRule('text-decoration', 'underline wavy');
expect(node1).toHaveStyleRule('color', 'rgb(96,107,133)');
expect(separator1).toHaveStyleRule('color', 'rgb(242,47,70)');

const node2 = breadcrumbItem2.firstChild as HTMLElement;
const separator2 = breadcrumbItem2.lastChild as HTMLElement;
expect(node2).toHaveStyleRule('text-decoration', 'underline wavy');
expect(node2).toHaveStyleRule('color', 'rgb(96,107,133)');
expect(separator2).toHaveStyleRule('color', 'rgb(242,47,70)');

const node3 = breadcrumbItem3.firstChild as HTMLElement;
expect(node3).toHaveStyleRule('letter-spacing', '0.25rem');
});

it('should add custom styles to Breadcrumb with a custom element data attribute', () => {
render(
<CustomizationProvider
baseTheme="default"
// @ts-expect-error global test variable
theme={TestTheme}
elements={{
CUSTOM: {marginY: 'space60', fontVariantNumeric: 'ordinal'},
CUSTOM_CHILD_ITEM: {fontWeight: 'fontWeightLight'},
CUSTOM_CHILD_ANCHOR: {fontWeight: 'fontWeightBold'},
CUSTOM_CHILD_SEPARATOR: {fontWeight: 'fontWeightLight'},
CUSTOM_CHILD_TEXT: {fontWeight: 'fontWeightSemibold'},
CUSTOM_ITEM: {fontWeight: 'fontWeightBold'},
CUSTOM_ANCHOR: {letterSpacing: '0.25rem'},
CUSTOM_SEPARATOR: {
color: 'colorTextBrandHighlight',
},
BREADCRUMB_ITEM: {fontWeight: 'fontWeightLight'},
BREADCRUMB_ANCHOR: {letterSpacing: '0.5rem'},
BREADCRUMB_SEPARATOR: {
color: 'colorText',
},
}}
>
<Breadcrumb element="CUSTOM" data-testid="breadcrumb">
<BreadcrumbItem element="CUSTOM_CHILD" href="#" data-testid="breadcrumb-item-1">
First
</BreadcrumbItem>
<BreadcrumbItem element="CUSTOM_CHILD" data-testid="breadcrumb-item-2">
Second
</BreadcrumbItem>
<BreadcrumbItem href="#" data-testid="breadcrumb-item-3">
Third
</BreadcrumbItem>
</Breadcrumb>
</CustomizationProvider>
);

expect(screen.getByTestId('breadcrumb')).toHaveStyleRule('font-variant-numeric', 'ordinal');

const breadcrumbItem1 = screen.getByTestId('breadcrumb-item-1') as HTMLElement;
const breadcrumbItem2 = screen.getByTestId('breadcrumb-item-2') as HTMLElement;
const breadcrumbItem3 = screen.getByTestId('breadcrumb-item-3') as HTMLElement;

expect(breadcrumbItem1).toHaveStyleRule('font-weight', '400');
expect(breadcrumbItem2).toHaveStyleRule('font-weight', '400');
expect(breadcrumbItem3).toHaveStyleRule('font-weight', '700');

const node1 = breadcrumbItem1.firstChild as HTMLElement;
const separator1 = breadcrumbItem1.lastChild as HTMLElement;
expect(node1).toHaveStyleRule('font-weight', '700');
expect(separator1).toHaveStyleRule('color', 'rgb(96,107,133)');

const node2 = breadcrumbItem2.firstChild as HTMLElement;
const separator2 = breadcrumbItem2.lastChild as HTMLElement;
expect(node2).toHaveStyleRule('font-weight', '600');
expect(separator2).toHaveStyleRule('color', 'rgb(96,107,133)');

const node3 = breadcrumbItem3.firstChild as HTMLElement;
expect(node3).toHaveStyleRule('letter-spacing', '0.25rem');
});
});
});
81 changes: 50 additions & 31 deletions packages/paste-core/components/breadcrumb/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import * as React from 'react';
import * as PropTypes from 'prop-types';
import {Box, safelySpreadBoxProps} from '@twilio-paste/box';
import type {BoxElementProps} from '@twilio-paste/box';
import {Anchor} from '@twilio-paste/anchor';
import {Text} from '@twilio-paste/text';
import {useUIDSeed} from '@twilio-paste/uid-library';

const BreadcrumbSeparator: React.FC = () => (
const BreadcrumbSeparator: React.FC<{element: BoxElementProps['element']}> = ({element}) => (
<Text
as="span"
color="colorTextWeak"
Expand All @@ -14,39 +15,53 @@ const BreadcrumbSeparator: React.FC = () => (
paddingLeft="space20"
paddingRight="space20"
role="presentation"
element={`${element}_SEPARATOR`}
>
/
</Text>
);

export interface BreadcrumbItemProps extends React.HTMLAttributes<HTMLLIElement> {
children: NonNullable<React.ReactNode>;
element?: BoxElementProps['element'];
parentElement?: BoxElementProps['element'];
href?: string;
last?: boolean;
}

const DEFAULT_ELEMENT_NAME = 'BREADCRUMB';

const BreadcrumbItem = React.forwardRef<HTMLAnchorElement, BreadcrumbItemProps>(
({children, href, last, ...props}, ref) => {
({children, element, parentElement, href, last, ...props}, ref) => {
const elementName = element || parentElement || DEFAULT_ELEMENT_NAME;
return (
<Box
{...safelySpreadBoxProps(props)}
alignItems="center"
as="li"
color="colorText"
display="inline-flex"
element={`${elementName}_ITEM`}
fontSize="fontSize20"
lineHeight="lineHeight20"
>
{href ? (
<Anchor href={href} ref={ref}>
<Anchor element={`${elementName}_ANCHOR`} href={href} ref={ref}>
{children}
</Anchor>
) : (
<Text aria-current="page" as="span" fontSize="fontSize20" lineHeight="lineHeight20" ref={ref}>
<Text
aria-current="page"
as="span"
element={`${elementName}_TEXT`}
fontSize="fontSize20"
lineHeight="lineHeight20"
ref={ref}
>
{children}
</Text>
)}
{!last && <BreadcrumbSeparator />}
{!last && <BreadcrumbSeparator element={elementName} />}
</Box>
);
}
Expand All @@ -57,45 +72,49 @@ BreadcrumbItem.displayName = 'BreadcrumbItem';
if (process.env.NODE_ENV === 'development') {
BreadcrumbItem.propTypes = {
children: PropTypes.node.isRequired,
element: PropTypes.string,
href: PropTypes.string,
last: PropTypes.bool,
};
}

export interface BreadcrumbProps extends React.HTMLAttributes<'nav'> {
children: NonNullable<React.ReactNode>;
element?: BoxElementProps['element'];
}

const Breadcrumb = React.forwardRef<HTMLDivElement, BreadcrumbProps>(({children, ...props}, ref) => {
const [childrenCount, validChildren] = React.useMemo(
() => [
React.Children.count(children),
React.Children.toArray(children).filter((child) => React.isValidElement(child) || typeof child === 'string'),
],
[children]
);
const keySeed = useUIDSeed();
const Breadcrumb = React.forwardRef<HTMLDivElement, BreadcrumbProps>(
({children, element = DEFAULT_ELEMENT_NAME, ...props}, ref) => {
const [childrenCount, validChildren] = React.useMemo(
() => [
React.Children.count(children),
React.Children.toArray(children).filter((child) => React.isValidElement(child) || typeof child === 'string'),
],
[children]
);
const keySeed = useUIDSeed();

return (
<Box {...safelySpreadBoxProps(props)} aria-label="breadcrumb" as="nav" ref={ref}>
<Box alignItems="center" as="ol" display="inline-flex" listStyleType="none" margin="space0" padding="space0">
{validChildren.map((child, index) =>
React.cloneElement(child as React.ReactElement<any>, {
last: childrenCount === index + 1,
key: keySeed(`breadcrumb-${index}`),
})
)}
return (
<Box {...safelySpreadBoxProps(props)} aria-label="breadcrumb" as="nav" element={element} ref={ref}>
<Box alignItems="center" as="ol" display="inline-flex" listStyleType="none" margin="space0" padding="space0">
{validChildren.map((child, index) => {
return React.cloneElement(child as React.ReactElement<any>, {
last: childrenCount === index + 1,
key: keySeed(`breadcrumb-${index}`),
parentElement: element,
});
})}
</Box>
</Box>
</Box>
);
});
);
}
);

Breadcrumb.displayName = 'Breadcrumb';

if (process.env.NODE_ENV === 'development') {
Breadcrumb.propTypes = {
children: PropTypes.node.isRequired,
};
}
Breadcrumb.propTypes = {
children: PropTypes.node.isRequired,
element: PropTypes.string,
};

export {Breadcrumb, BreadcrumbItem};
Loading

0 comments on commit 36cc8dc

Please sign in to comment.