@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.
Files changed (41) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +18 -0
  3. package/package.json +72 -0
  4. package/src/_lib/compat.ts +15 -0
  5. package/src/components/Carousel/index.story.tsx +86 -0
  6. package/src/components/Carousel/index.tsx +382 -0
  7. package/src/components/CarouselButton/index.story.tsx +44 -0
  8. package/src/components/CarouselButton/index.tsx +162 -0
  9. package/src/components/Filter/index.story.tsx +80 -0
  10. package/src/components/Filter/index.tsx +182 -0
  11. package/src/components/HintText/index.story.tsx +19 -0
  12. package/src/components/HintText/index.tsx +95 -0
  13. package/src/components/Layout/index.story.tsx +121 -0
  14. package/src/components/Layout/index.tsx +363 -0
  15. package/src/components/LeftMenu/index.tsx +68 -0
  16. package/src/components/MenuListItem/index.story.tsx +143 -0
  17. package/src/components/MenuListItem/index.tsx +226 -0
  18. package/src/components/Pager/index.story.tsx +102 -0
  19. package/src/components/Pager/index.tsx +255 -0
  20. package/src/components/Spinner/index.story.tsx +47 -0
  21. package/src/components/Spinner/index.tsx +86 -0
  22. package/src/components/SwitchCheckbox/index.story.tsx +32 -0
  23. package/src/components/SwitchCheckbox/index.tsx +147 -0
  24. package/src/components/TextEllipsis/helper.ts +57 -0
  25. package/src/components/TextEllipsis/index.story.tsx +41 -0
  26. package/src/components/TextEllipsis/index.tsx +35 -0
  27. package/src/components/WithIcon/index.story.tsx +145 -0
  28. package/src/components/WithIcon/index.tsx +158 -0
  29. package/src/components/icons/Base.tsx +75 -0
  30. package/src/components/icons/DotsIcon.tsx +33 -0
  31. package/src/components/icons/InfoIcon.tsx +30 -0
  32. package/src/components/icons/NextIcon.tsx +47 -0
  33. package/src/components/icons/WedgeIcon.tsx +57 -0
  34. package/src/foundation/contants.ts +6 -0
  35. package/src/foundation/hooks.ts +195 -0
  36. package/src/foundation/support.ts +29 -0
  37. package/src/foundation/utils.ts +31 -0
  38. package/src/index.ts +45 -0
  39. package/src/misc/storybook-helper.ts +17 -0
  40. package/src/styled.ts +3 -0
  41. 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
+ )