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: add Breadcrumbs component #253

Merged
merged 4 commits into from
Jun 1, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Breadcrumbs should redefined all separators 1`] = `
"<nav class=\\"\\"><span class=\\"breadcrums-item \\">test-1</span><div class=\\"separator \\">*<style>
.separator {
display: inline-flex;
margin: 0 8px;
user-select: none;
pointer-events: none;
align-items: center;
}
</style></div><span class=\\"breadcrums-item \\">test-2</span><style>
nav {
margin: 0;
padding: 0;
line-height: inherit;
color: #888;
font-size: 1rem;
box-sizing: border-box;
display: flex;
align-items: center;
}

nav :global(.link:hover) {
color: rgba(0, 112, 243, 0.85);
}

nav > :global(span:last-of-type) {
color: #444;
}

nav > :global(.separator:last-child) {
display: none;
}

nav :global(svg) {
width: 1em;
height: 1em;
margin: 0 4px;
}

nav :global(.breadcrums-item) {
display: inline-flex;
align-items: center;
}
</style></nav>"
`;

exports[`Breadcrumbs should render correctly 1`] = `
"<nav class=\\"\\"><span class=\\"breadcrums-item \\">test-1</span><style>
nav {
margin: 0;
padding: 0;
line-height: inherit;
color: #888;
font-size: 1rem;
box-sizing: border-box;
display: flex;
align-items: center;
}

nav :global(.link:hover) {
color: rgba(0, 112, 243, 0.85);
}

nav > :global(span:last-of-type) {
color: #444;
}

nav > :global(.separator:last-child) {
display: none;
}

nav :global(svg) {
width: 1em;
height: 1em;
margin: 0 4px;
}

nav :global(.breadcrums-item) {
display: inline-flex;
align-items: center;
}
</style></nav>"
`;
75 changes: 75 additions & 0 deletions components/breadcrumbs/__tests__/breadcrumbs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React from 'react'
import { mount } from 'enzyme'
import { Breadcrumbs } from 'components'

describe('Breadcrumbs', () => {
it('should render correctly', () => {
const wrapper = mount(
<Breadcrumbs>
<Breadcrumbs.Item>test-1</Breadcrumbs.Item>
</Breadcrumbs>,
)
expect(wrapper.html()).toMatchSnapshot()
expect(() => wrapper.unmount()).not.toThrow()
})

it('should redefined all separators', () => {
const wrapper = mount(
<Breadcrumbs separator="*">
<Breadcrumbs.Item>test-1</Breadcrumbs.Item>
<Breadcrumbs.Item>test-2</Breadcrumbs.Item>
</Breadcrumbs>,
)
expect(wrapper.html()).toMatchSnapshot()
expect(wrapper.html()).toContain('*')
expect(() => wrapper.unmount()).not.toThrow()
})

it('the specified separator should be redefined', () => {
const wrapper = mount(
<Breadcrumbs separator="*">
<Breadcrumbs.Item>test-1</Breadcrumbs.Item>
<Breadcrumbs.Separator>%</Breadcrumbs.Separator>
<Breadcrumbs.Item>test-2</Breadcrumbs.Item>
</Breadcrumbs>,
)
expect(wrapper.html()).toContain('%')
})

it('should render string when href missing', () => {
let wrapper = mount(
<Breadcrumbs>
<Breadcrumbs.Item>test-1</Breadcrumbs.Item>
</Breadcrumbs>,
)
let dom = wrapper.find('.breadcrums-item').at(0).getDOMNode()
expect(dom.tagName).toEqual('SPAN')

wrapper = mount(
<Breadcrumbs>
<Breadcrumbs.Item href="">test-1</Breadcrumbs.Item>
</Breadcrumbs>,
)
dom = wrapper.find('.breadcrums-item').at(0).getDOMNode()
expect(dom.tagName).toEqual('A')

wrapper = mount(
<Breadcrumbs>
<Breadcrumbs.Item nextLink>test-1</Breadcrumbs.Item>
</Breadcrumbs>,
)
dom = wrapper.find('.breadcrums-item').at(0).getDOMNode()
expect(dom.tagName).toEqual('A')
})

it('should trigger click event', () => {
const handler = jest.fn()
const wrapper = mount(
<Breadcrumbs>
<Breadcrumbs.Item onClick={handler}>test-1</Breadcrumbs.Item>
</Breadcrumbs>,
)
wrapper.find('.breadcrums-item').at(0).simulate('click')
expect(handler).toHaveBeenCalled()
})
})
61 changes: 61 additions & 0 deletions components/breadcrumbs/breadcrumbs-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Link from '../link'
import { Props as LinkBasicProps } from '../link/link'
import React, { useMemo } from 'react'
import withDefaults from '../utils/with-defaults'
import { pickChild } from '../utils/collections'
import BreadcrumbsSeparator from './breadcrumbs-separator'

interface Props {
href?: string
nextLink?: boolean
onClick?: (event: React.MouseEvent) => void
className?: string
}

const defaultProps = {
nextLink: false,
className: '',
}

type NativeAttrs = Omit<React.AnchorHTMLAttributes<any>, keyof Props>
type NativeLinkAttrs = Omit<NativeAttrs, keyof LinkBasicProps>
export type BreadcrumbsProps = Props & typeof defaultProps & NativeLinkAttrs

const BreadcrumbsItem = React.forwardRef<
HTMLAnchorElement,
React.PropsWithChildren<BreadcrumbsProps>
>(
(
{ href, nextLink, onClick, children, className, ...props },
ref: React.Ref<HTMLAnchorElement>,
) => {
const isLink = useMemo(() => href !== undefined || nextLink, [href, nextLink])
const [withoutSepChildren] = pickChild(children, BreadcrumbsSeparator)
const clickHandler = (event: React.MouseEvent) => {
onClick && onClick(event)
}

if (!isLink) {
return (
<span className={`breadcrums-item ${className}`} onClick={clickHandler}>
{withoutSepChildren}
</span>
)
}

return (
<Link
className={`breadcrums-item ${className}`}
href={href}
onClick={clickHandler}
ref={ref}
{...props}>
{withoutSepChildren}
</Link>
)
},
)

const MemoBreadcrumbsItem = React.memo(BreadcrumbsItem)

export default withDefaults(MemoBreadcrumbsItem, defaultProps)
37 changes: 37 additions & 0 deletions components/breadcrumbs/breadcrumbs-separator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react'
import withDefaults from '../utils/with-defaults'

interface Props {
className?: string
}

const defaultProps = {
className: '',
}

type NativeAttrs = Omit<React.HTMLAttributes<any>, keyof Props>
export type BreadcrumbsProps = Props & typeof defaultProps & NativeAttrs

const BreadcrumbsSeparator: React.FC<React.PropsWithChildren<BreadcrumbsProps>> = ({
children,
className,
}) => {
return (
<div className={`separator ${className}`}>
{children}
<style jsx>{`
.separator {
display: inline-flex;
margin: 0 8px;
user-select: none;
pointer-events: none;
align-items: center;
}
`}</style>
</div>
)
}

const MemoBreadcrumbsSeparator = React.memo(BreadcrumbsSeparator)

export default withDefaults(MemoBreadcrumbsSeparator, defaultProps)
114 changes: 114 additions & 0 deletions components/breadcrumbs/breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import React, { ReactNode, useMemo } from 'react'
import useTheme from '../styles/use-theme'
import BreadcrumbsItem from './breadcrumbs-item'
import BreadcrumbsSeparator from './breadcrumbs-separator'
import { addColorAlpha } from '../utils/color'
import { NormalSizes } from '../utils/prop-types'

interface Props {
size: NormalSizes
separator?: string | ReactNode
className?: string
}

const defaultProps = {
size: 'medium' as NormalSizes,
separator: '/',
className: '',
}

type NativeAttrs = Omit<React.HTMLAttributes<any>, keyof Props>
export type BreadcrumbsProps = Props & typeof defaultProps & NativeAttrs

const getSize = (size: NormalSizes) => {
const sizes: { [key in NormalSizes]: string } = {
mini: '.75rem',
small: '.875rem',
medium: '1rem',
large: '1.125rem',
}
return sizes[size]
}

const Breadcrumbs: React.FC<React.PropsWithChildren<BreadcrumbsProps>> = ({
size,
separator,
children,
className,
}) => {
const theme = useTheme()
const fontSize = useMemo(() => getSize(size), [size])
const hoverColor = useMemo(() => {
return addColorAlpha(theme.palette.link, 0.85)
}, [theme.palette.link])

const childrenArray = React.Children.toArray(children)
const withSeparatorChildren = childrenArray.map((item, index) => {
if (!React.isValidElement(item)) return item
const last = childrenArray[index - 1]
const lastIsSeparator = React.isValidElement(last) && last.type === BreadcrumbsSeparator
const currentIsSeparator = item.type === BreadcrumbsSeparator
if (!lastIsSeparator && !currentIsSeparator && index > 0) {
return (
<React.Fragment key={index}>
<BreadcrumbsSeparator>{separator}</BreadcrumbsSeparator>
{item}
</React.Fragment>
)
}
return item
})

return (
<nav className={className}>
{withSeparatorChildren}
<style jsx>{`
nav {
margin: 0;
padding: 0;
line-height: inherit;
color: ${theme.palette.accents_4};
font-size: ${fontSize};
box-sizing: border-box;
display: flex;
align-items: center;
}

nav :global(.link:hover) {
color: ${hoverColor};
}

nav > :global(span:last-of-type) {
color: ${theme.palette.accents_6};
}

nav > :global(.separator:last-child) {
display: none;
}

nav :global(svg) {
width: 1em;
height: 1em;
margin: 0 4px;
}

nav :global(.breadcrums-item) {
display: inline-flex;
align-items: center;
}
`}</style>
</nav>
)
}

type MemoBreadcrumbsComponent<P = {}> = React.NamedExoticComponent<P> & {
Item: typeof BreadcrumbsItem
Separator: typeof BreadcrumbsSeparator
}
type ComponentProps = Partial<typeof defaultProps> &
Omit<Props, keyof typeof defaultProps> &
NativeAttrs

Breadcrumbs.defaultProps = defaultProps

export default React.memo(Breadcrumbs) as MemoBreadcrumbsComponent<ComponentProps>
8 changes: 8 additions & 0 deletions components/breadcrumbs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Breadcrumbs from './breadcrumbs'
import BreadcrumbsItem from './breadcrumbs-item'
import BreadcrumbsSeparator from './breadcrumbs-separator'

Breadcrumbs.Item = BreadcrumbsItem
Breadcrumbs.Separator = BreadcrumbsSeparator

export default Breadcrumbs
1 change: 1 addition & 0 deletions components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,4 @@ export { default as User } from './user'
export { default as Page } from './page'
export { default as Grid } from './grid'
export { default as ButtonGroup } from './button-group'
export { default as Breadcrumbs } from './breadcrumbs'
2 changes: 1 addition & 1 deletion components/link/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import useTheme from '../styles/use-theme'
import useWarning from '../utils/use-warning'
import LinkIcon from './icon'

interface Props {
export interface Props {
href?: string
color?: boolean
pure?: boolean
Expand Down
Loading