@charcoal-ui/react-sandbox 1.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +18 -0
- package/package.json +72 -0
- package/src/_lib/compat.ts +15 -0
- package/src/components/Carousel/index.story.tsx +86 -0
- package/src/components/Carousel/index.tsx +382 -0
- package/src/components/CarouselButton/index.story.tsx +44 -0
- package/src/components/CarouselButton/index.tsx +162 -0
- package/src/components/Filter/index.story.tsx +80 -0
- package/src/components/Filter/index.tsx +182 -0
- package/src/components/HintText/index.story.tsx +19 -0
- package/src/components/HintText/index.tsx +95 -0
- package/src/components/Layout/index.story.tsx +121 -0
- package/src/components/Layout/index.tsx +363 -0
- package/src/components/LeftMenu/index.tsx +68 -0
- package/src/components/MenuListItem/index.story.tsx +143 -0
- package/src/components/MenuListItem/index.tsx +226 -0
- package/src/components/Pager/index.story.tsx +102 -0
- package/src/components/Pager/index.tsx +255 -0
- package/src/components/Spinner/index.story.tsx +47 -0
- package/src/components/Spinner/index.tsx +86 -0
- package/src/components/SwitchCheckbox/index.story.tsx +32 -0
- package/src/components/SwitchCheckbox/index.tsx +147 -0
- package/src/components/TextEllipsis/helper.ts +57 -0
- package/src/components/TextEllipsis/index.story.tsx +41 -0
- package/src/components/TextEllipsis/index.tsx +35 -0
- package/src/components/WithIcon/index.story.tsx +145 -0
- package/src/components/WithIcon/index.tsx +158 -0
- package/src/components/icons/Base.tsx +75 -0
- package/src/components/icons/DotsIcon.tsx +33 -0
- package/src/components/icons/InfoIcon.tsx +30 -0
- package/src/components/icons/NextIcon.tsx +47 -0
- package/src/components/icons/WedgeIcon.tsx +57 -0
- package/src/foundation/contants.ts +6 -0
- package/src/foundation/hooks.ts +195 -0
- package/src/foundation/support.ts +29 -0
- package/src/foundation/utils.ts +31 -0
- package/src/index.ts +45 -0
- package/src/misc/storybook-helper.ts +17 -0
- package/src/styled.ts +3 -0
- package/src/type.d.ts +12 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import React, { useContext } from 'react'
|
|
2
|
+
import styled, { css } from 'styled-components'
|
|
3
|
+
import { theme } from '../../styled'
|
|
4
|
+
import { TextEllipsis } from '../TextEllipsis'
|
|
5
|
+
import { LinkProps, useComponentAbstraction } from '@charcoal-ui/react'
|
|
6
|
+
import { disabledSelector } from '@charcoal-ui/utils'
|
|
7
|
+
|
|
8
|
+
interface MenuListItemContextProps {
|
|
9
|
+
padding: 16 | 24
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const MenuListItemContext =
|
|
13
|
+
React.createContext<MenuListItemContextProps>({ padding: 24 })
|
|
14
|
+
|
|
15
|
+
export interface MenuListItemBaseData {
|
|
16
|
+
primary: string | React.ReactNode // 表示アイテム名(上に表示)
|
|
17
|
+
secondary?: string // 表示アイテム名2(下に表示)
|
|
18
|
+
onClick?: (e: React.MouseEvent) => void
|
|
19
|
+
disabled?: boolean
|
|
20
|
+
gtmClass?: string
|
|
21
|
+
noHover?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface MenuListItemProps extends MenuListItemBaseData {
|
|
25
|
+
children?: React.ReactNode // 右寄せで表示したい要素
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function MenuListItem({
|
|
29
|
+
primary,
|
|
30
|
+
secondary,
|
|
31
|
+
onClick,
|
|
32
|
+
disabled = false,
|
|
33
|
+
noHover = false,
|
|
34
|
+
gtmClass,
|
|
35
|
+
children,
|
|
36
|
+
}: MenuListItemProps) {
|
|
37
|
+
const { padding } = useContext(MenuListItemContext)
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Item
|
|
41
|
+
hasSubLabel={secondary !== undefined}
|
|
42
|
+
onClick={(e) => !disabled && onClick && onClick(e)}
|
|
43
|
+
sidePadding={padding}
|
|
44
|
+
noHover={noHover}
|
|
45
|
+
noClick={onClick === undefined}
|
|
46
|
+
aria-disabled={disabled}
|
|
47
|
+
role={onClick !== undefined ? 'button' : undefined}
|
|
48
|
+
className={gtmClass !== undefined ? `gtm-${gtmClass}` : undefined}
|
|
49
|
+
>
|
|
50
|
+
<Labels>
|
|
51
|
+
<PrimaryText>
|
|
52
|
+
<TextEllipsis lineHeight={22} lineLimit={1}>
|
|
53
|
+
{primary}
|
|
54
|
+
</TextEllipsis>
|
|
55
|
+
</PrimaryText>
|
|
56
|
+
{secondary !== undefined && (
|
|
57
|
+
<SecondaryText>
|
|
58
|
+
<TextEllipsis lineHeight={22} lineLimit={1}>
|
|
59
|
+
{secondary}
|
|
60
|
+
</TextEllipsis>
|
|
61
|
+
</SecondaryText>
|
|
62
|
+
)}
|
|
63
|
+
</Labels>
|
|
64
|
+
{children}
|
|
65
|
+
</Item>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ItemProps {
|
|
70
|
+
hasSubLabel: boolean
|
|
71
|
+
sidePadding: 16 | 24
|
|
72
|
+
noHover: boolean
|
|
73
|
+
noClick: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const Item = styled.div<ItemProps>`
|
|
77
|
+
display: flex;
|
|
78
|
+
height: ${(p) => (p.hasSubLabel ? 56 : 40)}px;
|
|
79
|
+
align-items: center;
|
|
80
|
+
justify-content: space-between;
|
|
81
|
+
padding: 0 ${(p) => p.sidePadding}px;
|
|
82
|
+
user-select: none;
|
|
83
|
+
cursor: ${(p) => (p.noClick ? 'default' : 'pointer')};
|
|
84
|
+
transition: 0.2s background-color;
|
|
85
|
+
|
|
86
|
+
&:hover {
|
|
87
|
+
${(p) =>
|
|
88
|
+
!p.noHover &&
|
|
89
|
+
css`
|
|
90
|
+
background-color: ${({ theme }) => theme.color.surface3};
|
|
91
|
+
`}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
${theme((o) => o.disabled)}
|
|
95
|
+
|
|
96
|
+
${disabledSelector} {
|
|
97
|
+
cursor: default;
|
|
98
|
+
pointer-events: none;
|
|
99
|
+
|
|
100
|
+
&:hover {
|
|
101
|
+
background-color: unset;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
`
|
|
105
|
+
|
|
106
|
+
const Labels = styled.div`
|
|
107
|
+
display: flex;
|
|
108
|
+
flex-direction: column;
|
|
109
|
+
`
|
|
110
|
+
|
|
111
|
+
const PrimaryText = styled.div`
|
|
112
|
+
color: ${(p) => p.theme.color.text2};
|
|
113
|
+
line-height: 22px;
|
|
114
|
+
font-size: 14px;
|
|
115
|
+
display: grid;
|
|
116
|
+
`
|
|
117
|
+
|
|
118
|
+
const SecondaryText = styled.div`
|
|
119
|
+
color: ${(p) => p.theme.color.text3};
|
|
120
|
+
line-height: 18px;
|
|
121
|
+
font-size: 10px;
|
|
122
|
+
`
|
|
123
|
+
|
|
124
|
+
interface MenuListLinkItemProps
|
|
125
|
+
extends MenuListItemBaseData,
|
|
126
|
+
Omit<LinkProps, 'to' | 'onClick' | 'children'> {
|
|
127
|
+
link: string
|
|
128
|
+
children?: React.ReactNode
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function MenuListLinkItem({
|
|
132
|
+
link,
|
|
133
|
+
onClick,
|
|
134
|
+
disabled = false,
|
|
135
|
+
primary,
|
|
136
|
+
secondary,
|
|
137
|
+
gtmClass,
|
|
138
|
+
noHover,
|
|
139
|
+
children,
|
|
140
|
+
...linkProps
|
|
141
|
+
}: MenuListLinkItemProps) {
|
|
142
|
+
const { Link } = useComponentAbstraction()
|
|
143
|
+
const props: MenuListItemProps = {
|
|
144
|
+
disabled,
|
|
145
|
+
primary,
|
|
146
|
+
secondary,
|
|
147
|
+
gtmClass,
|
|
148
|
+
noHover,
|
|
149
|
+
children,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return disabled ? (
|
|
153
|
+
<span onClick={onClick}>
|
|
154
|
+
<MenuListItem {...props} />
|
|
155
|
+
</span>
|
|
156
|
+
) : (
|
|
157
|
+
<A<typeof Link> as={Link} to={link} onClick={onClick} {...linkProps}>
|
|
158
|
+
<MenuListItem onClick={() => void 0} {...props} />
|
|
159
|
+
</A>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const A = styled.a`
|
|
164
|
+
display: block;
|
|
165
|
+
`
|
|
166
|
+
|
|
167
|
+
interface MenuListLinkItemWithIconProps extends MenuListLinkItemProps {
|
|
168
|
+
icon: React.ReactNode
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface MenuListItemWithIconProps extends MenuListItemProps {
|
|
172
|
+
icon: React.ReactNode
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function MenuListLinkItemWithIcon({
|
|
176
|
+
icon,
|
|
177
|
+
primary: text,
|
|
178
|
+
...props
|
|
179
|
+
}: MenuListLinkItemWithIconProps) {
|
|
180
|
+
const primary = (
|
|
181
|
+
<IconContainer>
|
|
182
|
+
<Icon>{icon}</Icon>
|
|
183
|
+
{text}
|
|
184
|
+
</IconContainer>
|
|
185
|
+
)
|
|
186
|
+
return <MenuListLinkItem primary={primary} {...props} />
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function MenuListItemWithIcon({
|
|
190
|
+
icon,
|
|
191
|
+
primary: text,
|
|
192
|
+
...props
|
|
193
|
+
}: MenuListItemWithIconProps) {
|
|
194
|
+
const primary = (
|
|
195
|
+
<IconContainer>
|
|
196
|
+
<Icon>{icon}</Icon>
|
|
197
|
+
{text}
|
|
198
|
+
</IconContainer>
|
|
199
|
+
)
|
|
200
|
+
return <MenuListItem primary={primary} {...props} />
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const IconContainer = styled.div`
|
|
204
|
+
display: grid;
|
|
205
|
+
gap: 8px;
|
|
206
|
+
grid-auto-flow: column;
|
|
207
|
+
align-items: center;
|
|
208
|
+
`
|
|
209
|
+
|
|
210
|
+
const Icon = styled.div`
|
|
211
|
+
color: ${({ theme }) => theme.color.text3};
|
|
212
|
+
display: flex;
|
|
213
|
+
`
|
|
214
|
+
|
|
215
|
+
export const MenuListSpacer = styled.div`
|
|
216
|
+
height: 24px;
|
|
217
|
+
`
|
|
218
|
+
|
|
219
|
+
export const MenuListLabel = styled.div`
|
|
220
|
+
padding: 0 16px;
|
|
221
|
+
font-size: 12px;
|
|
222
|
+
line-height: 16px;
|
|
223
|
+
color: ${({ theme }) => theme.color.text3};
|
|
224
|
+
margin-top: -2px;
|
|
225
|
+
margin-bottom: 6px;
|
|
226
|
+
`
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
MemoryRouter as Router,
|
|
4
|
+
Route,
|
|
5
|
+
Link as RouterLink,
|
|
6
|
+
useParams,
|
|
7
|
+
} from 'react-router-dom'
|
|
8
|
+
import { Story } from '../../_lib/compat'
|
|
9
|
+
import Pager, { LinkPager } from '.'
|
|
10
|
+
import { ComponentAbstraction } from '@charcoal-ui/react'
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
title: 'Sandbox/Pager',
|
|
14
|
+
component: Pager,
|
|
15
|
+
argTypes: {
|
|
16
|
+
page: {
|
|
17
|
+
control: {
|
|
18
|
+
type: 'number',
|
|
19
|
+
disable: true,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
pageCount: {
|
|
23
|
+
control: {
|
|
24
|
+
type: 'number',
|
|
25
|
+
min: 1,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
page: number
|
|
33
|
+
pageCount: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DefaultStory: Story<Props> = ({ page: defaultPage, pageCount }) => {
|
|
37
|
+
const [page, setPage] = useState(defaultPage)
|
|
38
|
+
return <Pager page={page} onChange={setPage} pageCount={pageCount} />
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const Default = DefaultStory.bind({})
|
|
42
|
+
Default.args = {
|
|
43
|
+
page: 1,
|
|
44
|
+
pageCount: 10,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const makeUrl = (page: number) => `/${page}`
|
|
48
|
+
|
|
49
|
+
const LinkStory: Story<Props> = ({ page: defaultPage, pageCount }) => (
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
51
|
+
// @ts-ignore
|
|
52
|
+
<ComponentAbstraction components={{ Link: RouterLink }}>
|
|
53
|
+
<Router
|
|
54
|
+
initialEntries={Array.from({ length: pageCount }).map((_, i) =>
|
|
55
|
+
makeUrl(i + 1)
|
|
56
|
+
)}
|
|
57
|
+
initialIndex={defaultPage - 1}
|
|
58
|
+
>
|
|
59
|
+
<Route
|
|
60
|
+
path="/:page"
|
|
61
|
+
element={<CurrentPager pageCount={pageCount}></CurrentPager>}
|
|
62
|
+
/>
|
|
63
|
+
</Router>
|
|
64
|
+
</ComponentAbstraction>
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
function CurrentPager({ pageCount }: { pageCount: number }) {
|
|
68
|
+
const params = useParams()
|
|
69
|
+
const page = params.page !== undefined ? parseInt(params.page, 10) : 1
|
|
70
|
+
|
|
71
|
+
return <LinkPager makeUrl={makeUrl} page={page} pageCount={pageCount} />
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const Link = LinkStory.bind({})
|
|
75
|
+
Link.args = {
|
|
76
|
+
page: 1,
|
|
77
|
+
pageCount: 10,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const NotCollapsed = LinkStory.bind({})
|
|
81
|
+
NotCollapsed.args = {
|
|
82
|
+
page: 4,
|
|
83
|
+
pageCount: 7,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const CollapsedWithDots = LinkStory.bind({})
|
|
87
|
+
CollapsedWithDots.args = {
|
|
88
|
+
page: 5,
|
|
89
|
+
pageCount: 8,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const LastPage = LinkStory.bind({})
|
|
93
|
+
LastPage.args = {
|
|
94
|
+
page: 103,
|
|
95
|
+
pageCount: 103,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const One = LinkStory.bind({})
|
|
99
|
+
One.args = {
|
|
100
|
+
page: 1,
|
|
101
|
+
pageCount: 1,
|
|
102
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import React, { useCallback, useDebugValue, useMemo } from 'react'
|
|
2
|
+
|
|
3
|
+
import styled, { css } from 'styled-components'
|
|
4
|
+
import warning from 'warning'
|
|
5
|
+
import DotsIcon from '../icons/DotsIcon'
|
|
6
|
+
import WedgeIcon, { WedgeDirection } from '../icons/WedgeIcon'
|
|
7
|
+
import { useComponentAbstraction } from '@charcoal-ui/react'
|
|
8
|
+
|
|
9
|
+
declare const __DEV__: object | undefined // actually object|false, but using undefined allows ! assertion
|
|
10
|
+
|
|
11
|
+
function usePagerWindow(page: number, pageCount: number, windowSize = 7) {
|
|
12
|
+
// ページャーのリンク生成例:
|
|
13
|
+
//
|
|
14
|
+
// < [ 1 ] [*2*] [ 3 ] [ 4 ] [ 5 ] [ 6 ] [ 7 ] >
|
|
15
|
+
//
|
|
16
|
+
// < [ 1 ] [ 2 ] [ 3 ] [*4*] [ 5 ] [ 6 ] [ 7 ] >
|
|
17
|
+
//
|
|
18
|
+
// < [ 1 ] ... [ 4 ] [*5*] [ 6 ] [ 7 ] [ 8 ] >
|
|
19
|
+
//
|
|
20
|
+
// < [ 1 ] ... [ 99 ] [*100*] [ 101 ] [ 102 ] [ 103 ] >
|
|
21
|
+
//
|
|
22
|
+
// < [ 1 ] ... [ 99 ] [ 100 ] [ 101 ] [ 102 ] [*103*]
|
|
23
|
+
//
|
|
24
|
+
// [*1*] [ 2 ] >
|
|
25
|
+
//
|
|
26
|
+
// デザインの意図: 前後移動時のカーソル移動を最小限にする。
|
|
27
|
+
|
|
28
|
+
if (__DEV__) {
|
|
29
|
+
warning((page | 0) === page, `\`page\` must be interger (${page})`)
|
|
30
|
+
warning(
|
|
31
|
+
(pageCount | 0) === pageCount,
|
|
32
|
+
`\`pageCount\` must be interger (${pageCount})`
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const window = useMemo(() => {
|
|
37
|
+
const visibleFirstPage = 1
|
|
38
|
+
const visibleLastPage = Math.min(
|
|
39
|
+
pageCount,
|
|
40
|
+
Math.max(page + Math.floor(windowSize / 2), windowSize)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if (visibleLastPage <= windowSize) {
|
|
44
|
+
// 表示範囲が1-7ページなら省略は無い。
|
|
45
|
+
return Array.from(
|
|
46
|
+
{ length: 1 + visibleLastPage - visibleFirstPage },
|
|
47
|
+
(_, i) => visibleFirstPage + i
|
|
48
|
+
)
|
|
49
|
+
} else {
|
|
50
|
+
const start = visibleLastPage - (windowSize - 1) + 2
|
|
51
|
+
return [
|
|
52
|
+
// 表示範囲が1-7ページを超えるなら、
|
|
53
|
+
// - 1ページ目は固定で表示する
|
|
54
|
+
visibleFirstPage,
|
|
55
|
+
// - 2ページ目から現在のページの直前までは省略する
|
|
56
|
+
'...' as const,
|
|
57
|
+
...Array.from(
|
|
58
|
+
{ length: 1 + visibleLastPage - start },
|
|
59
|
+
(_, i) => start + i
|
|
60
|
+
),
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
}, [page, pageCount, windowSize])
|
|
64
|
+
|
|
65
|
+
useDebugValue(window)
|
|
66
|
+
|
|
67
|
+
return window
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface CommonProps {
|
|
71
|
+
page: number
|
|
72
|
+
pageCount: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface PagerProps extends CommonProps {
|
|
76
|
+
onChange(newPage: number): void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// this pager is just regular buttons; for links use LinkPager
|
|
80
|
+
export default React.memo(function Pager({
|
|
81
|
+
page,
|
|
82
|
+
pageCount,
|
|
83
|
+
onChange,
|
|
84
|
+
}: PagerProps) {
|
|
85
|
+
// TODO: refactor Pager and LinkPager to use a common parent component
|
|
86
|
+
const window = usePagerWindow(page, pageCount)
|
|
87
|
+
const makeClickHandler = useCallback(
|
|
88
|
+
(value: number) => () => {
|
|
89
|
+
onChange(value)
|
|
90
|
+
},
|
|
91
|
+
[onChange]
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const hasNext = page < pageCount
|
|
95
|
+
const hasPrev = page > 1
|
|
96
|
+
return (
|
|
97
|
+
<PagerContainer>
|
|
98
|
+
<CircleButton
|
|
99
|
+
type="button"
|
|
100
|
+
hidden={!hasPrev}
|
|
101
|
+
disabled={!hasPrev}
|
|
102
|
+
onClick={makeClickHandler(Math.max(1, page - 1))}
|
|
103
|
+
noBackground
|
|
104
|
+
>
|
|
105
|
+
<WedgeIcon size={16} direction={WedgeDirection.Left} />
|
|
106
|
+
</CircleButton>
|
|
107
|
+
{window.map((p) =>
|
|
108
|
+
p === '...' ? (
|
|
109
|
+
<Spacer key={p}>
|
|
110
|
+
<DotsIcon size={20} />
|
|
111
|
+
</Spacer>
|
|
112
|
+
) : p === page ? (
|
|
113
|
+
// we remove the onClick but don't mark it as disabled to preserve keyboard focus
|
|
114
|
+
// not doing so causes the focus ring to flicker in and out of existence
|
|
115
|
+
<CircleButton key={p} type="button" aria-current>
|
|
116
|
+
<Text>{p}</Text>
|
|
117
|
+
</CircleButton>
|
|
118
|
+
) : (
|
|
119
|
+
<CircleButton key={p} type="button" onClick={makeClickHandler(p)}>
|
|
120
|
+
<Text>{p}</Text>
|
|
121
|
+
</CircleButton>
|
|
122
|
+
)
|
|
123
|
+
)}
|
|
124
|
+
<CircleButton
|
|
125
|
+
type="button"
|
|
126
|
+
hidden={!hasNext}
|
|
127
|
+
disabled={!hasNext}
|
|
128
|
+
onClick={makeClickHandler(Math.min(pageCount, page + 1))}
|
|
129
|
+
noBackground
|
|
130
|
+
>
|
|
131
|
+
<WedgeIcon size={16} direction={WedgeDirection.Right} />
|
|
132
|
+
</CircleButton>
|
|
133
|
+
</PagerContainer>
|
|
134
|
+
)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
export interface LinkPagerProps extends CommonProps {
|
|
138
|
+
makeUrl(page: number): string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function LinkPager({ page, pageCount, makeUrl }: LinkPagerProps) {
|
|
142
|
+
const { Link } = useComponentAbstraction()
|
|
143
|
+
const window = usePagerWindow(page, pageCount)
|
|
144
|
+
|
|
145
|
+
const hasNext = page < pageCount
|
|
146
|
+
const hasPrev = page > 1
|
|
147
|
+
return (
|
|
148
|
+
<PagerContainer>
|
|
149
|
+
<Link to={makeUrl(Math.max(1, page - 1))}>
|
|
150
|
+
<CircleButton hidden={!hasPrev} aria-disabled={!hasPrev} noBackground>
|
|
151
|
+
<WedgeIcon size={16} direction={WedgeDirection.Left} />
|
|
152
|
+
</CircleButton>
|
|
153
|
+
</Link>
|
|
154
|
+
{window.map((p) =>
|
|
155
|
+
p === '...' ? (
|
|
156
|
+
<Spacer key={p}>
|
|
157
|
+
<DotsIcon size={20} subLink />
|
|
158
|
+
</Spacer>
|
|
159
|
+
) : p === page ? (
|
|
160
|
+
<CircleButton key={p} type="button" aria-current>
|
|
161
|
+
<Text>{p}</Text>
|
|
162
|
+
</CircleButton>
|
|
163
|
+
) : (
|
|
164
|
+
<Link key={p} to={makeUrl(p)}>
|
|
165
|
+
<CircleButton type="button">
|
|
166
|
+
<Text>{p}</Text>
|
|
167
|
+
</CircleButton>
|
|
168
|
+
</Link>
|
|
169
|
+
)
|
|
170
|
+
)}
|
|
171
|
+
<Link to={makeUrl(Math.min(pageCount, page + 1))}>
|
|
172
|
+
<CircleButton hidden={!hasNext} aria-disabled={!hasNext} noBackground>
|
|
173
|
+
<WedgeIcon size={16} direction={WedgeDirection.Right} />
|
|
174
|
+
</CircleButton>
|
|
175
|
+
</Link>
|
|
176
|
+
</PagerContainer>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const PagerContainer = styled.nav`
|
|
181
|
+
display: flex;
|
|
182
|
+
justify-content: center;
|
|
183
|
+
align-items: center;
|
|
184
|
+
`
|
|
185
|
+
|
|
186
|
+
const CircleButton = styled.button`
|
|
187
|
+
font-size: 1rem;
|
|
188
|
+
line-height: calc(1em + 8px);
|
|
189
|
+
text-decoration: none;
|
|
190
|
+
border: none;
|
|
191
|
+
outline: none;
|
|
192
|
+
touch-action: manipulation;
|
|
193
|
+
user-select: none;
|
|
194
|
+
transition: box-shadow 0.2s ease 0s, color 0.2s ease 0s,
|
|
195
|
+
background 0.2s ease 0s, opacity 0.2s ease 0s;
|
|
196
|
+
|
|
197
|
+
display: flex;
|
|
198
|
+
justify-content: center;
|
|
199
|
+
align-items: center;
|
|
200
|
+
box-sizing: content-box;
|
|
201
|
+
min-width: 24px;
|
|
202
|
+
min-height: 24px;
|
|
203
|
+
padding: 8px;
|
|
204
|
+
cursor: pointer;
|
|
205
|
+
font-weight: bold;
|
|
206
|
+
/* HACK:
|
|
207
|
+
* Safari doesn't correctly repaint the elements when they're reordered in response to interaction.
|
|
208
|
+
* This forces it to repaint them. This doesn't work if put on the parents either, has to be here.
|
|
209
|
+
*/
|
|
210
|
+
/* stylelint-disable-next-line property-no-vendor-prefix */
|
|
211
|
+
-webkit-transform: translateZ(0);
|
|
212
|
+
|
|
213
|
+
&[hidden] {
|
|
214
|
+
visibility: hidden;
|
|
215
|
+
display: block;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
border-radius: 48px;
|
|
219
|
+
|
|
220
|
+
background: transparent;
|
|
221
|
+
color: ${({ theme }) => theme.color.text3};
|
|
222
|
+
|
|
223
|
+
&:hover {
|
|
224
|
+
background: ${({ theme }) => theme.color.surface3};
|
|
225
|
+
color: ${({ theme }) => theme.color.text2};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
&[aria-current] {
|
|
229
|
+
background-color: ${({ theme }) => theme.color.surface6};
|
|
230
|
+
color: ${({ theme }) => theme.color.text5};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
&[aria-current]:hover {
|
|
234
|
+
background-color: ${({ theme }) => theme.color.surface6};
|
|
235
|
+
color: ${({ theme }) => theme.color.text5};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
${({ noBackground = false }: { noBackground?: boolean }) =>
|
|
239
|
+
noBackground &&
|
|
240
|
+
css`
|
|
241
|
+
/* stylelint-disable-next-line no-duplicate-selectors */
|
|
242
|
+
&:hover {
|
|
243
|
+
background: transparent;
|
|
244
|
+
}
|
|
245
|
+
`}
|
|
246
|
+
`
|
|
247
|
+
|
|
248
|
+
const Spacer = styled(CircleButton).attrs({ type: 'button', disabled: true })`
|
|
249
|
+
&& {
|
|
250
|
+
color: ${({ theme }) => theme.color.text3};
|
|
251
|
+
background: none;
|
|
252
|
+
}
|
|
253
|
+
`
|
|
254
|
+
|
|
255
|
+
const Text = 'span'
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {
|
|
2
|
+
boolean,
|
|
3
|
+
button,
|
|
4
|
+
number,
|
|
5
|
+
text,
|
|
6
|
+
withKnobs,
|
|
7
|
+
} from '@storybook/addon-knobs'
|
|
8
|
+
import React, { useRef } from 'react'
|
|
9
|
+
import Spinner, { SpinnerIcon, SpinnerIconHandler } from '.'
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
title: 'Sandbox/Spinner',
|
|
13
|
+
component: Spinner,
|
|
14
|
+
decorators: [withKnobs],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Basic() {
|
|
18
|
+
const size = number('size', 48)
|
|
19
|
+
const padding = number('padding', 16)
|
|
20
|
+
const transparent = boolean('transparent', false)
|
|
21
|
+
|
|
22
|
+
return <Spinner size={size} padding={padding} transparent={transparent} />
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function Icon() {
|
|
26
|
+
return <IconComponent />
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function IconComponent() {
|
|
30
|
+
const size = number('size', 12)
|
|
31
|
+
const color = text('color', '#B1CC29')
|
|
32
|
+
const once = boolean('once', false)
|
|
33
|
+
button('restart', () => ref.current?.restart())
|
|
34
|
+
|
|
35
|
+
const ref = useRef<SpinnerIconHandler>(null)
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
css={`
|
|
40
|
+
font-size: ${size}px;
|
|
41
|
+
color: ${color};
|
|
42
|
+
`}
|
|
43
|
+
>
|
|
44
|
+
<SpinnerIcon once={once} ref={ref} />
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { transparentize } from 'polished'
|
|
2
|
+
import React, { useImperativeHandle, useRef } from 'react'
|
|
3
|
+
import styled, { keyframes } from 'styled-components'
|
|
4
|
+
|
|
5
|
+
export default function Spinner({
|
|
6
|
+
size = 48,
|
|
7
|
+
padding = 16,
|
|
8
|
+
transparent = false,
|
|
9
|
+
}) {
|
|
10
|
+
return (
|
|
11
|
+
<SpinnerRoot size={size} padding={padding} transparent={transparent}>
|
|
12
|
+
<SpinnerIcon />
|
|
13
|
+
</SpinnerRoot>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SpinnerRoot = styled.div.attrs({ role: 'progressbar' })<{
|
|
18
|
+
size: number
|
|
19
|
+
padding: number
|
|
20
|
+
transparent: boolean
|
|
21
|
+
}>`
|
|
22
|
+
margin: auto;
|
|
23
|
+
padding: ${(props) => props.padding}px;
|
|
24
|
+
border-radius: 8px;
|
|
25
|
+
font-size: ${(props) => props.size}px;
|
|
26
|
+
width: ${(props) => props.size}px;
|
|
27
|
+
height: ${(props) => props.size}px;
|
|
28
|
+
background-color: ${({ theme, transparent }) =>
|
|
29
|
+
transparent
|
|
30
|
+
? 'transparent'
|
|
31
|
+
: transparentize(0.32, theme.color.background1)};
|
|
32
|
+
color: ${({ theme }) => theme.color.text4};
|
|
33
|
+
`
|
|
34
|
+
|
|
35
|
+
const scaleout = keyframes`
|
|
36
|
+
from {
|
|
37
|
+
transform: scale(0);
|
|
38
|
+
opacity: 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
to {
|
|
42
|
+
transform: scale(1);
|
|
43
|
+
opacity: 0;
|
|
44
|
+
}
|
|
45
|
+
`
|
|
46
|
+
|
|
47
|
+
const Icon = styled.div.attrs({ role: 'presentation' })<{ once: boolean }>`
|
|
48
|
+
width: 1em;
|
|
49
|
+
height: 1em;
|
|
50
|
+
border-radius: 1em;
|
|
51
|
+
background-color: currentColor;
|
|
52
|
+
animation: ${scaleout} 1s both ease-out;
|
|
53
|
+
animation-iteration-count: ${(p) => (p.once ? 1 : 'infinite')};
|
|
54
|
+
|
|
55
|
+
&[data-reset-animation] {
|
|
56
|
+
animation: none;
|
|
57
|
+
}
|
|
58
|
+
`
|
|
59
|
+
|
|
60
|
+
interface Props {
|
|
61
|
+
once?: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SpinnerIconHandler {
|
|
65
|
+
restart(): void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const SpinnerIcon = React.forwardRef<SpinnerIconHandler, Props>(
|
|
69
|
+
function SpinnerIcon({ once = false }, ref) {
|
|
70
|
+
const iconRef = useRef<HTMLDivElement>(null)
|
|
71
|
+
|
|
72
|
+
useImperativeHandle(ref, () => ({
|
|
73
|
+
restart: () => {
|
|
74
|
+
if (!iconRef.current) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
iconRef.current.dataset.resetAnimation = 'true'
|
|
78
|
+
// Force reflow hack!
|
|
79
|
+
void iconRef.current.offsetWidth
|
|
80
|
+
delete iconRef.current.dataset.resetAnimation
|
|
81
|
+
},
|
|
82
|
+
}))
|
|
83
|
+
|
|
84
|
+
return <Icon ref={iconRef} once={once} />
|
|
85
|
+
}
|
|
86
|
+
)
|