feat(pagination): add component

This commit is contained in:
unix
2020-06-09 08:37:56 +08:00
parent 1b84205f27
commit 81b192dfec
9 changed files with 472 additions and 0 deletions

View File

@@ -59,3 +59,4 @@ 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'
export { default as Pagination } from './pagination'

View File

@@ -0,0 +1,8 @@
import Pagination from './pagination'
import PaginationPrevious from './pagination-previous'
import PaginationNext from './pagination-next'
Pagination.Previous = PaginationPrevious
Pagination.Next = PaginationNext
export default Pagination

View File

@@ -0,0 +1,18 @@
import React from 'react'
import { tuple } from '../utils/prop-types'
const paginationUpdateTypes = tuple('prev', 'next', 'click')
export type PaginationUpdateType = typeof paginationUpdateTypes[number]
export interface PaginationConfig {
isFirst?: boolean
isLast?: boolean
update?: (type: PaginationUpdateType) => void
}
const defaultContext = {}
export const PaginationContext = React.createContext<PaginationConfig>(defaultContext)
export const usePaginationContext = (): PaginationConfig =>
React.useContext<PaginationConfig>(PaginationContext)

View File

@@ -0,0 +1,60 @@
import React, { useState } from 'react'
import PaginationItem from './pagination-item'
interface Props {
isBefore?: boolean
onClick?: (e: React.MouseEvent) => void
}
const PaginationEllipsis: React.FC<Props> = ({ isBefore, onClick }) => {
const [showMore, setShowMore] = useState(false)
return (
<PaginationItem
onClick={e => onClick && onClick(e)}
onMouseEnter={() => setShowMore(true)}
onMouseLeave={() => setShowMore(false)}>
{showMore ? (
<svg
className="more"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision">
<path d="M13 17l5-5-5-5" />
<path d="M6 17l5-5-5-5" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision">
<circle cx="12" cy="12" r="1" fill="currentColor" />
<circle cx="19" cy="12" r="1" fill="currentColor" />
<circle cx="5" cy="12" r="1" fill="currentColor" />
</svg>
)}
<style jsx>{`
svg {
color: currentColor;
stroke: currentColor;
width: 1rem;
height: 1rem;
}
.more {
transform: rotate(${isBefore ? '180deg' : '0deg'});
}
`}</style>
</PaginationItem>
)
}
export default PaginationEllipsis

View File

@@ -0,0 +1,101 @@
import React, { useMemo } from 'react'
import useTheme from '../styles/use-theme'
import { addColorAlpha } from 'components/utils/color'
interface Props {
active?: boolean
disabled?: boolean
onClick?: (e: React.MouseEvent) => void
}
type NativeAttrs = Omit<React.ButtonHTMLAttributes<any>, keyof Props>
export type PaginationItemProps = Props & NativeAttrs
const PaginationItem: React.FC<React.PropsWithChildren<PaginationItemProps>> = ({
active,
children,
disabled,
onClick,
...props
}) => {
const theme = useTheme()
const [hover, activeHover] = useMemo(
() => [addColorAlpha(theme.palette.success, 0.1), addColorAlpha(theme.palette.success, 0.8)],
[theme.palette.success],
)
const clickHandler = (event: React.MouseEvent) => {
if (disabled) return
onClick && onClick(event)
}
return (
<li>
<button
className={`${active ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
onClick={clickHandler}
{...props}>
{children}
</button>
<style jsx>{`
li {
margin-right: 6px;
display: inline-block;
}
button {
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
text-transform: capitalize;
user-select: none;
white-space: nowrap;
text-align: center;
vertical-align: middle;
box-shadow: none;
outline: none;
height: var(--pagination-size);
min-width: var(--pagination-size);
font-size: inherit;
cursor: pointer;
color: ${theme.palette.success};
border-radius: ${theme.layout.radius};
background-color: ${theme.palette.background};
transition: all linear 200ms 0ms;
}
button:hover {
background-color: ${hover};
}
.active {
font-weight: bold;
background-color: ${theme.palette.success};
color: ${theme.palette.background};
box-shadow: ${theme.expressiveness.shadowSmall};
}
.active:hover {
background-color: ${activeHover};
box-shadow: ${theme.expressiveness.shadowMedium};
}
.disabled {
color: ${theme.palette.accents_4};
cursor: not-allowed;
}
.disabled:hover {
background-color: ${theme.palette.accents_2};
}
button :global(svg) {
width: 1.3em;
height: 1.3em;
}
`}</style>
</li>
)
}
export default PaginationItem

View File

@@ -0,0 +1,19 @@
import React from 'react'
import PaginationItem from './pagination-item'
import { usePaginationContext } from './pagination-context'
export type PaginationNextProps = React.ButtonHTMLAttributes<any>
const PaginationNext: React.FC<React.PropsWithChildren<PaginationNextProps>> = ({
children,
...props
}) => {
const { update, isLast } = usePaginationContext()
return (
<PaginationItem onClick={() => update && update('next')} disabled={isLast} {...props}>
{children}
</PaginationItem>
)
}
export default PaginationNext

View File

@@ -0,0 +1,104 @@
import React, { Dispatch, SetStateAction, useCallback, useMemo } from 'react'
import PaginationItem from './pagination-item'
import PaginationEllipsis from 'components/pagination/pagination-ellipsis'
interface Props {
limit: number
count: number
current: number
setPage: Dispatch<SetStateAction<number>>
}
const PaginationPages: React.FC<Props> = ({ limit, count, current, setPage }) => {
const showPages = useMemo(() => {
const oddLimit = limit % 2 === 0 ? limit - 1 : limit
return oddLimit - 2
}, [limit])
const middleNumber = (showPages + 1) / 2
const [showBeforeEllipsis, showAfterEllipsis] = useMemo(() => {
const showEllipsis = count > limit
return [
showEllipsis && current > middleNumber + 1,
showEllipsis && current < count - middleNumber,
]
}, [current, showPages, middleNumber, count, limit])
const pagesArray = useMemo(() => [...new Array(showPages)], [showPages])
const renderItem = useCallback(
(value: number, active: number) => (
<PaginationItem
key={`pagination-item-${value}`}
active={value === active}
onClick={() => setPage(value)}>
{value}
</PaginationItem>
),
[],
)
const startPages = pagesArray.map((_, index) => {
const value = index + 2
return renderItem(value, current)
})
const middlePages = pagesArray.map((_, index) => {
const middleIndexNumber = middleNumber - (index + 1)
const value = current - middleIndexNumber
return (
<PaginationItem
key={`pagination-middle-${index}`}
active={index + 1 === middleNumber}
onClick={() => setPage(value)}>
{value}
</PaginationItem>
)
})
const endPages = pagesArray.map((_, index) => {
const value = count - (showPages - index)
return renderItem(value, current)
})
if (count <= limit) {
/* eslint-disable react/jsx-no-useless-fragment */
return (
<>
{[...new Array(count)].map((_, index) => {
const value = index + 1
return (
<PaginationItem
key={`pagination-item-${value}`}
active={value === current}
onClick={() => setPage(value)}>
{value}
</PaginationItem>
)
})}
</>
)
/* eslint-enable */
}
return (
<>
{renderItem(1, current)}
{showBeforeEllipsis && (
<PaginationEllipsis
key="pagination-ellipsis-before"
isBefore
onClick={() => setPage(last => (last - 5 >= 1 ? last - 5 : 1))}
/>
)}
{showBeforeEllipsis && showAfterEllipsis
? middlePages
: showBeforeEllipsis
? endPages
: startPages}
{showAfterEllipsis && (
<PaginationEllipsis
key="pagination-ellipsis-after"
onClick={() => setPage(last => (last + 5 <= count ? last + 5 : count))}
/>
)}
{renderItem(count, current)}
</>
)
}
export default PaginationPages

View File

@@ -0,0 +1,19 @@
import React from 'react'
import PaginationItem from './pagination-item'
import { usePaginationContext } from './pagination-context'
export type PaginationNextProps = React.ButtonHTMLAttributes<any>
const PaginationPrevious: React.FC<React.PropsWithChildren<PaginationNextProps>> = ({
children,
...props
}) => {
const { update, isFirst } = usePaginationContext()
return (
<PaginationItem onClick={() => update && update('prev')} disabled={isFirst} {...props}>
{children}
</PaginationItem>
)
}
export default PaginationPrevious

View File

@@ -0,0 +1,142 @@
import React, { useEffect, useMemo } from 'react'
import PaginationPrevious from './pagination-previous'
import PaginationNext from './pagination-next'
import PaginationPages from './pagination-pages'
import { PaginationContext, PaginationConfig, PaginationUpdateType } from './pagination-context'
import useCurrentState from '../utils/use-current-state'
import { pickChild } from '../utils/collections'
import { NormalSizes } from '../utils/prop-types'
interface Props {
size?: NormalSizes
page?: number
initialPage?: number
count?: number
limit?: number
onChange?: (val: number) => void
}
const defaultProps = {
size: 'medium' as NormalSizes,
initialPage: 1,
count: 1,
limit: 7,
}
type NativeAttrs = Omit<React.HTMLAttributes<any>, keyof Props>
export type PaginationProps = Props & typeof defaultProps & NativeAttrs
type PaginationSize = {
font: string
width: string
}
const getPaginationSizes = (size: NormalSizes) => {
const sizes: { [key in NormalSizes]: PaginationSize } = {
mini: {
font: '.75rem',
width: '1.25rem',
},
small: {
font: '.75rem',
width: '1.65rem',
},
medium: {
font: '.875rem',
width: '2rem',
},
large: {
font: '1rem',
width: '2.4rem',
},
}
return sizes[size]
}
const Pagination: React.FC<React.PropsWithChildren<PaginationProps>> = ({
page: customPage,
initialPage,
count,
limit,
size,
children,
onChange,
}) => {
const [page, setPage, pageRef] = useCurrentState(initialPage)
const [, prevChildren] = pickChild(children, PaginationPrevious)
const [, nextChildren] = pickChild(children, PaginationNext)
const [prevItem, nextItem] = useMemo(() => {
const hasChildren = (c: any) => React.Children.count(c) > 0
const prevDefault = <PaginationPrevious>prev</PaginationPrevious>
const nextDefault = <PaginationNext>next</PaginationNext>
return [
hasChildren(prevChildren) ? prevChildren : prevDefault,
hasChildren(nextChildren) ? nextChildren : nextDefault,
]
}, [prevChildren, nextChildren])
const { font, width } = useMemo(() => getPaginationSizes(size), [size])
const update = (type: PaginationUpdateType) => {
if (type === 'prev' && pageRef.current > 1) {
setPage(last => last - 1)
}
if (type === 'next' && pageRef.current < count) {
setPage(last => last + 1)
}
}
const values = useMemo<PaginationConfig>(
() => ({
isFirst: page <= 1,
isLast: page >= count,
update,
}),
[page],
)
useEffect(() => {
onChange && onChange(page)
}, [page])
useEffect(() => {
if (customPage !== undefined) {
setPage(customPage)
}
}, [customPage])
return (
<PaginationContext.Provider value={values}>
<nav>
{prevItem}
<PaginationPages count={count} current={page} limit={limit} setPage={setPage} />
{nextItem}
</nav>
<style jsx>{`
nav {
margin: 0;
padding: 0;
font-variant: tabular-nums;
font-feature-settings: 'tnum';
font-size: ${font};
--pagination-size: ${width};
}
nav :global(button:last-of-type) {
margin-right: 0;
}
`}</style>
</PaginationContext.Provider>
)
}
type MemoPaginationComponent<P = {}> = React.NamedExoticComponent<P> & {
Previous: typeof PaginationPrevious
Next: typeof PaginationNext
}
type ComponentProps = Partial<typeof defaultProps> &
Omit<Props, keyof typeof defaultProps> &
NativeAttrs
Pagination.defaultProps = defaultProps
export default React.memo(Pagination) as MemoPaginationComponent<ComponentProps>