@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,382 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useEffect,
|
|
3
|
+
useState,
|
|
4
|
+
useCallback,
|
|
5
|
+
useRef,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
} from 'react'
|
|
8
|
+
import { animated, useSpring } from 'react-spring'
|
|
9
|
+
import styled, { css } from 'styled-components'
|
|
10
|
+
import { useDebounceAnimationState } from '../../foundation/hooks'
|
|
11
|
+
import { passiveEvents, isEdge } from '../../foundation/support'
|
|
12
|
+
import CarouselButton, { Direction } from '../CarouselButton'
|
|
13
|
+
|
|
14
|
+
export const GRADIENT_WIDTH = 72
|
|
15
|
+
/**
|
|
16
|
+
* カルーセル系のスクロール量の定数
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const scrollAmount = containerElm.clientWidth * SCROLL_AMOUNT_COEF
|
|
20
|
+
*/
|
|
21
|
+
export const SCROLL_AMOUNT_COEF = 0.75
|
|
22
|
+
|
|
23
|
+
interface ScrollProps {
|
|
24
|
+
align?: 'center' | 'left' | 'right'
|
|
25
|
+
offset?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CarouselBaseAppearanceProps {
|
|
29
|
+
buttonOffset?: number
|
|
30
|
+
buttonPadding?: number
|
|
31
|
+
bottomOffset?: number
|
|
32
|
+
defaultScroll?: ScrollProps
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type CarouselGradientProps =
|
|
36
|
+
| { hasGradient?: false }
|
|
37
|
+
| {
|
|
38
|
+
hasGradient: true
|
|
39
|
+
fadeInGradient?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type CarouselAppearanceProps = CarouselBaseAppearanceProps &
|
|
43
|
+
CarouselGradientProps
|
|
44
|
+
|
|
45
|
+
type Props = CarouselAppearanceProps & {
|
|
46
|
+
onScroll?: (left: number) => void
|
|
47
|
+
onResize?: (width: number) => void
|
|
48
|
+
children: React.ReactNode
|
|
49
|
+
centerItems?: boolean
|
|
50
|
+
onScrollStateChange?: (canScroll: boolean) => void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CarouselHandlerRef {
|
|
54
|
+
resetScroll(): void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default function Carousel({
|
|
58
|
+
buttonOffset = 0,
|
|
59
|
+
buttonPadding = 16,
|
|
60
|
+
bottomOffset = 0,
|
|
61
|
+
defaultScroll: { align = 'left', offset: scrollOffset = 0 } = {},
|
|
62
|
+
onScroll,
|
|
63
|
+
onResize,
|
|
64
|
+
children,
|
|
65
|
+
centerItems,
|
|
66
|
+
onScrollStateChange,
|
|
67
|
+
...options
|
|
68
|
+
}: Props) {
|
|
69
|
+
// スクロール位置を保存する
|
|
70
|
+
// アニメーション中の場合は、アニメーション終了時のスクロール位置が保存される
|
|
71
|
+
const [scrollLeft, setScrollLeft] = useDebounceAnimationState(0)
|
|
72
|
+
// アニメーション中かどうか
|
|
73
|
+
const animation = useRef(false)
|
|
74
|
+
// スクロール可能な領域を保存する
|
|
75
|
+
const [maxScrollLeft, setMaxScrollLeft] = useState(0)
|
|
76
|
+
// 左右のボタンの表示状態を保存する
|
|
77
|
+
const [leftShow, setLeftShow] = useState(false)
|
|
78
|
+
const [rightShow, setRightShow] = useState(false)
|
|
79
|
+
|
|
80
|
+
// const [props, set, stop] = useSpring(() => ({
|
|
81
|
+
// scroll: 0
|
|
82
|
+
// }))
|
|
83
|
+
const [styles, set] = useSpring(() => ({ scroll: 0 }))
|
|
84
|
+
|
|
85
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
86
|
+
const innerRef = useRef<HTMLUListElement>(null)
|
|
87
|
+
|
|
88
|
+
const handleRight = useCallback(() => {
|
|
89
|
+
if (ref.current === null) {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
const { clientWidth } = ref.current
|
|
93
|
+
// スクロール領域を超えないように、アニメーションを開始
|
|
94
|
+
// アニメーション中にアニメーションが開始されたときに、アニメーション終了予定の位置から再度計算するようにする
|
|
95
|
+
const scroll = Math.min(
|
|
96
|
+
scrollLeft + clientWidth * SCROLL_AMOUNT_COEF,
|
|
97
|
+
maxScrollLeft
|
|
98
|
+
)
|
|
99
|
+
setScrollLeft(scroll, true)
|
|
100
|
+
set({ scroll, from: { scroll: scrollLeft }, reset: !animation.current })
|
|
101
|
+
animation.current = true
|
|
102
|
+
}, [animation, maxScrollLeft, scrollLeft, set, setScrollLeft])
|
|
103
|
+
|
|
104
|
+
const handleLeft = useCallback(() => {
|
|
105
|
+
if (ref.current === null) {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
const { clientWidth } = ref.current
|
|
109
|
+
const scroll = Math.max(scrollLeft - clientWidth * SCROLL_AMOUNT_COEF, 0)
|
|
110
|
+
setScrollLeft(scroll, true)
|
|
111
|
+
set({ scroll, from: { scroll: scrollLeft }, reset: !animation.current })
|
|
112
|
+
animation.current = true
|
|
113
|
+
}, [animation, scrollLeft, set, setScrollLeft])
|
|
114
|
+
|
|
115
|
+
// スクロール可能な場合にボタンを表示する
|
|
116
|
+
// scrollLeftが変化したときに処理する (アニメーション開始時 & 手動スクロール時)
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const newleftShow = scrollLeft > 0
|
|
119
|
+
const newrightShow = scrollLeft < maxScrollLeft && maxScrollLeft > 0
|
|
120
|
+
if (newleftShow !== leftShow || newrightShow !== rightShow) {
|
|
121
|
+
setLeftShow(newleftShow)
|
|
122
|
+
setRightShow(newrightShow)
|
|
123
|
+
onScrollStateChange?.(newleftShow || newrightShow)
|
|
124
|
+
}
|
|
125
|
+
}, [leftShow, maxScrollLeft, onScrollStateChange, rightShow, scrollLeft])
|
|
126
|
+
|
|
127
|
+
const handleScroll = useCallback(() => {
|
|
128
|
+
if (ref.current === null) {
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
// 手動でスクロールが開始されたときにアニメーションを中断
|
|
132
|
+
if (animation.current) {
|
|
133
|
+
styles.scroll.stop()
|
|
134
|
+
animation.current = false
|
|
135
|
+
}
|
|
136
|
+
// スクロール位置を保存 (アニメーションの基準になる)
|
|
137
|
+
const manualScrollLeft = ref.current.scrollLeft
|
|
138
|
+
// 過剰にsetStateが走らないようにDebouceする
|
|
139
|
+
setScrollLeft(manualScrollLeft)
|
|
140
|
+
}, [animation, setScrollLeft, styles])
|
|
141
|
+
|
|
142
|
+
// リサイズが起きたときに、アニメーション用のスクロール領域 & ボタンの表示状態 を再計算する
|
|
143
|
+
const handleResize = useCallback(() => {
|
|
144
|
+
if (ref.current === null) {
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
const { clientWidth, scrollWidth } = ref.current
|
|
148
|
+
const newMaxScrollLeft = scrollWidth - clientWidth
|
|
149
|
+
setMaxScrollLeft(newMaxScrollLeft)
|
|
150
|
+
if (onResize) {
|
|
151
|
+
onResize(clientWidth)
|
|
152
|
+
}
|
|
153
|
+
}, [onResize])
|
|
154
|
+
|
|
155
|
+
const resizeObserverRef = useRef(new ResizeObserver(handleResize))
|
|
156
|
+
const resizeObserverInnerRef = useRef(new ResizeObserver(handleResize))
|
|
157
|
+
|
|
158
|
+
useLayoutEffect(() => {
|
|
159
|
+
const elm = ref.current
|
|
160
|
+
const innerElm = innerRef.current
|
|
161
|
+
if (elm === null || innerElm === null) {
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
elm.addEventListener(
|
|
166
|
+
'wheel',
|
|
167
|
+
handleScroll,
|
|
168
|
+
passiveEvents() && { passive: true }
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const resizeObserver = resizeObserverRef.current
|
|
172
|
+
resizeObserver.observe(elm)
|
|
173
|
+
|
|
174
|
+
const resizeObserverInner = resizeObserverInnerRef.current
|
|
175
|
+
resizeObserverInner.observe(innerElm)
|
|
176
|
+
|
|
177
|
+
return () => {
|
|
178
|
+
elm.removeEventListener('wheel', handleScroll)
|
|
179
|
+
resizeObserver.disconnect()
|
|
180
|
+
resizeObserverInner.disconnect()
|
|
181
|
+
}
|
|
182
|
+
}, [handleResize, handleScroll])
|
|
183
|
+
|
|
184
|
+
// 初期スクロールを行う
|
|
185
|
+
useLayoutEffect(() => {
|
|
186
|
+
if (align !== 'left' || scrollOffset !== 0) {
|
|
187
|
+
const scroll = ref.current
|
|
188
|
+
if (scroll !== null) {
|
|
189
|
+
const scrollLength = Math.max(
|
|
190
|
+
0,
|
|
191
|
+
Math.min(
|
|
192
|
+
align === 'left' && scrollOffset > 0
|
|
193
|
+
? scrollOffset
|
|
194
|
+
: align === 'center'
|
|
195
|
+
? maxScrollLeft / 2 + scrollOffset
|
|
196
|
+
: align === 'right' && scrollOffset <= maxScrollLeft
|
|
197
|
+
? maxScrollLeft - scrollOffset / 2
|
|
198
|
+
: 0,
|
|
199
|
+
maxScrollLeft
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
scroll.scrollLeft = scrollLength
|
|
203
|
+
setScrollLeft(scrollLength, true)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
207
|
+
}, [ref.current])
|
|
208
|
+
|
|
209
|
+
const handleScrollMove = useCallback(() => {
|
|
210
|
+
if (ref.current === null) {
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
if (onScroll) {
|
|
214
|
+
onScroll(ref.current.scrollLeft)
|
|
215
|
+
}
|
|
216
|
+
}, [onScroll])
|
|
217
|
+
|
|
218
|
+
// NOTE: Edgeではmaskを使うと要素のレンダリングがバグる(場合によっては画像が表示されない)のでグラデーションを無効にする
|
|
219
|
+
if (!isEdge && options.hasGradient === true) {
|
|
220
|
+
const fadeInGradient = options.fadeInGradient ?? false
|
|
221
|
+
const overflowGradient = !fadeInGradient
|
|
222
|
+
return (
|
|
223
|
+
<Container>
|
|
224
|
+
<GradientContainer fadeInGradient={fadeInGradient}>
|
|
225
|
+
<RightGradient>
|
|
226
|
+
<LeftGradient show={overflowGradient || scrollLeft > 0}>
|
|
227
|
+
<ScrollArea
|
|
228
|
+
ref={ref}
|
|
229
|
+
scrollLeft={styles.scroll}
|
|
230
|
+
onScroll={handleScrollMove}
|
|
231
|
+
>
|
|
232
|
+
<CarouselContainer ref={innerRef} centerItems={centerItems}>
|
|
233
|
+
{children}
|
|
234
|
+
</CarouselContainer>
|
|
235
|
+
</ScrollArea>
|
|
236
|
+
</LeftGradient>
|
|
237
|
+
</RightGradient>
|
|
238
|
+
</GradientContainer>
|
|
239
|
+
<ButtonsContainer>
|
|
240
|
+
<CarouselButton
|
|
241
|
+
direction={Direction.Left}
|
|
242
|
+
show={leftShow}
|
|
243
|
+
offset={buttonOffset}
|
|
244
|
+
bottomOffset={bottomOffset}
|
|
245
|
+
padding={buttonPadding}
|
|
246
|
+
gradient={overflowGradient}
|
|
247
|
+
onClick={handleLeft}
|
|
248
|
+
/>
|
|
249
|
+
<CarouselButton
|
|
250
|
+
direction={Direction.Right}
|
|
251
|
+
show={rightShow}
|
|
252
|
+
offset={buttonOffset}
|
|
253
|
+
bottomOffset={bottomOffset}
|
|
254
|
+
padding={buttonPadding}
|
|
255
|
+
gradient
|
|
256
|
+
onClick={handleRight}
|
|
257
|
+
/>
|
|
258
|
+
</ButtonsContainer>
|
|
259
|
+
</Container>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<Container>
|
|
265
|
+
<ScrollArea
|
|
266
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
267
|
+
// @ts-expect-error
|
|
268
|
+
ref={ref}
|
|
269
|
+
scrollLeft={styles.scroll}
|
|
270
|
+
onScroll={handleScrollMove}
|
|
271
|
+
>
|
|
272
|
+
<CarouselContainer ref={innerRef} centerItems={centerItems}>
|
|
273
|
+
{children}
|
|
274
|
+
</CarouselContainer>
|
|
275
|
+
</ScrollArea>
|
|
276
|
+
<ButtonsContainer>
|
|
277
|
+
<CarouselButton
|
|
278
|
+
direction={Direction.Left}
|
|
279
|
+
show={leftShow}
|
|
280
|
+
offset={buttonOffset}
|
|
281
|
+
bottomOffset={bottomOffset}
|
|
282
|
+
padding={buttonPadding}
|
|
283
|
+
onClick={handleLeft}
|
|
284
|
+
/>
|
|
285
|
+
<CarouselButton
|
|
286
|
+
direction={Direction.Right}
|
|
287
|
+
show={rightShow}
|
|
288
|
+
offset={buttonOffset}
|
|
289
|
+
bottomOffset={bottomOffset}
|
|
290
|
+
padding={buttonPadding}
|
|
291
|
+
onClick={handleRight}
|
|
292
|
+
/>
|
|
293
|
+
</ButtonsContainer>
|
|
294
|
+
</Container>
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const CarouselContainer = styled.ul<{ centerItems?: boolean }>`
|
|
299
|
+
vertical-align: top;
|
|
300
|
+
overflow: hidden;
|
|
301
|
+
list-style: none;
|
|
302
|
+
padding: 0;
|
|
303
|
+
|
|
304
|
+
/* 最小幅を100%にして親要素にぴったりくっつけることで子要素で要素を均等に割り付けるなどを出来るようにしてある */
|
|
305
|
+
min-width: 100%;
|
|
306
|
+
box-sizing: border-box;
|
|
307
|
+
|
|
308
|
+
${({ centerItems = false }) =>
|
|
309
|
+
centerItems
|
|
310
|
+
? css`
|
|
311
|
+
display: flex;
|
|
312
|
+
width: max-content;
|
|
313
|
+
margin: 0 auto;
|
|
314
|
+
`
|
|
315
|
+
: css`
|
|
316
|
+
display: inline-flex;
|
|
317
|
+
margin: 0;
|
|
318
|
+
`}
|
|
319
|
+
`
|
|
320
|
+
|
|
321
|
+
const ButtonsContainer = styled.div`
|
|
322
|
+
opacity: 0;
|
|
323
|
+
transition: 0.4s opacity;
|
|
324
|
+
`
|
|
325
|
+
const Container = styled.div`
|
|
326
|
+
&:hover ${ButtonsContainer} {
|
|
327
|
+
opacity: 1;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/* CarouselButtonの中にz-index:1があるのでここでコンテキストを切る */
|
|
331
|
+
position: relative;
|
|
332
|
+
z-index: 0;
|
|
333
|
+
`
|
|
334
|
+
|
|
335
|
+
const ScrollArea = styled(animated.div)`
|
|
336
|
+
overflow-x: auto;
|
|
337
|
+
padding: 0;
|
|
338
|
+
margin: 0;
|
|
339
|
+
|
|
340
|
+
&::-webkit-scrollbar {
|
|
341
|
+
display: none;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
scrollbar-width: none;
|
|
345
|
+
`
|
|
346
|
+
|
|
347
|
+
const GradientContainer = styled.div<{ fadeInGradient: boolean }>`
|
|
348
|
+
/* NOTE: LeftGradientがはみ出るためhidden */
|
|
349
|
+
overflow: hidden;
|
|
350
|
+
${(p) =>
|
|
351
|
+
!p.fadeInGradient &&
|
|
352
|
+
css`
|
|
353
|
+
margin-left: ${-GRADIENT_WIDTH}px;
|
|
354
|
+
${CarouselContainer} {
|
|
355
|
+
padding-left: ${GRADIENT_WIDTH}px;
|
|
356
|
+
}
|
|
357
|
+
`}
|
|
358
|
+
|
|
359
|
+
margin-right: ${-GRADIENT_WIDTH}px;
|
|
360
|
+
/* stylelint-disable-next-line no-duplicate-selectors */
|
|
361
|
+
${CarouselContainer} {
|
|
362
|
+
padding-right: ${GRADIENT_WIDTH}px;
|
|
363
|
+
}
|
|
364
|
+
`
|
|
365
|
+
|
|
366
|
+
const RightGradient = styled.div`
|
|
367
|
+
mask-image: linear-gradient(
|
|
368
|
+
to right,
|
|
369
|
+
#000 calc(100% - ${GRADIENT_WIDTH}px),
|
|
370
|
+
transparent
|
|
371
|
+
);
|
|
372
|
+
`
|
|
373
|
+
|
|
374
|
+
const LeftGradient = styled.div<{ show: boolean }>`
|
|
375
|
+
/* NOTE: mask-position が left → negative px の時、right → abs(negative px) の位置に表示されるため */
|
|
376
|
+
margin-right: ${-GRADIENT_WIDTH}px;
|
|
377
|
+
padding-right: ${GRADIENT_WIDTH}px;
|
|
378
|
+
/* NOTE: mask-position に transition をつけたいが vender prefixes 対策で all につける */
|
|
379
|
+
transition: 0.2s all ease-in;
|
|
380
|
+
mask: linear-gradient(to right, transparent, #000 ${GRADIENT_WIDTH}px)
|
|
381
|
+
${(p) => (p.show ? 0 : -GRADIENT_WIDTH)}px 0;
|
|
382
|
+
`
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { action } from '@storybook/addon-actions'
|
|
2
|
+
import { boolean, number, select, withKnobs } from '@storybook/addon-knobs'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import CarouselButton, { Direction, ScrollHintButton } from '.'
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
title: 'Sandbox/Carousel/CarouselButton',
|
|
8
|
+
decorators: [withKnobs],
|
|
9
|
+
component: CarouselButton,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const _CarouselButton = () => {
|
|
13
|
+
const direction = select(
|
|
14
|
+
'direction',
|
|
15
|
+
{ left: Direction.Left, right: Direction.Right },
|
|
16
|
+
Direction.Left
|
|
17
|
+
)
|
|
18
|
+
const show = boolean('show', true)
|
|
19
|
+
const offset = number('offset', 0)
|
|
20
|
+
const padding = number('padding', 0)
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<CarouselButton
|
|
24
|
+
direction={direction}
|
|
25
|
+
show={show}
|
|
26
|
+
offset={offset}
|
|
27
|
+
padding={padding}
|
|
28
|
+
onClick={noop}
|
|
29
|
+
/>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function noop() {
|
|
34
|
+
// empty
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const _ScrollHintButton = () => {
|
|
38
|
+
const direction = select(
|
|
39
|
+
'direction',
|
|
40
|
+
{ left: Direction.Left, right: Direction.Right },
|
|
41
|
+
Direction.Left
|
|
42
|
+
)
|
|
43
|
+
return <ScrollHintButton direction={direction} onClick={action('click')} />
|
|
44
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import styled, { css } from 'styled-components'
|
|
3
|
+
import { unreachable } from '../../foundation/utils'
|
|
4
|
+
import NextIcon, { WedgeDirection } from '../icons/NextIcon'
|
|
5
|
+
import { applyEffect } from '@charcoal-ui/utils'
|
|
6
|
+
|
|
7
|
+
export enum Direction {
|
|
8
|
+
Right = 'right',
|
|
9
|
+
Left = 'left',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
direction: Direction
|
|
14
|
+
show: boolean
|
|
15
|
+
offset?: number
|
|
16
|
+
padding?: number
|
|
17
|
+
bottomOffset?: number
|
|
18
|
+
gradient?: boolean
|
|
19
|
+
onClick(): void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function CarouselButton({
|
|
23
|
+
direction,
|
|
24
|
+
show,
|
|
25
|
+
offset = 0,
|
|
26
|
+
padding = 0,
|
|
27
|
+
bottomOffset: bottom = 0,
|
|
28
|
+
gradient = false,
|
|
29
|
+
onClick,
|
|
30
|
+
}: Props) {
|
|
31
|
+
const offsetStyle =
|
|
32
|
+
direction === Direction.Left
|
|
33
|
+
? {
|
|
34
|
+
left: gradient ? offset - 72 : offset,
|
|
35
|
+
paddingLeft: Math.max(padding, 0),
|
|
36
|
+
paddingBottom: bottom,
|
|
37
|
+
}
|
|
38
|
+
: {
|
|
39
|
+
right: gradient ? offset - 72 : offset,
|
|
40
|
+
paddingRight: Math.max(padding, 0),
|
|
41
|
+
paddingBottom: bottom,
|
|
42
|
+
}
|
|
43
|
+
return (
|
|
44
|
+
<Button
|
|
45
|
+
type="button"
|
|
46
|
+
onClick={onClick}
|
|
47
|
+
hide={!show}
|
|
48
|
+
style={offsetStyle}
|
|
49
|
+
css={onlyNonTouchDevice}
|
|
50
|
+
>
|
|
51
|
+
<CarouselButtonIcon>
|
|
52
|
+
<NextIcon
|
|
53
|
+
direction={
|
|
54
|
+
direction === Direction.Right
|
|
55
|
+
? WedgeDirection.Right
|
|
56
|
+
: // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
57
|
+
direction === Direction.Left
|
|
58
|
+
? WedgeDirection.Left
|
|
59
|
+
: unreachable()
|
|
60
|
+
}
|
|
61
|
+
/>
|
|
62
|
+
</CarouselButtonIcon>
|
|
63
|
+
</Button>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const CAROUSEL_BUTTON_SIZE = 40
|
|
68
|
+
|
|
69
|
+
const CarouselButtonIcon = styled.div`
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
justify-content: center;
|
|
73
|
+
width: ${CAROUSEL_BUTTON_SIZE}px;
|
|
74
|
+
height: ${CAROUSEL_BUTTON_SIZE}px;
|
|
75
|
+
border-radius: 50%;
|
|
76
|
+
background-color: ${({ theme }) => theme.color.surface4};
|
|
77
|
+
transition: 0.4s visibility, 0.4s opacity, 0.2s background-color, 0.2s color;
|
|
78
|
+
color: ${({ theme }) => theme.color.text5};
|
|
79
|
+
`
|
|
80
|
+
|
|
81
|
+
const Button = styled.button<{ hide: boolean }>`
|
|
82
|
+
position: absolute;
|
|
83
|
+
top: 0;
|
|
84
|
+
bottom: 0;
|
|
85
|
+
display: flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
padding: 0;
|
|
88
|
+
min-width: 40px;
|
|
89
|
+
border: none;
|
|
90
|
+
outline: 0;
|
|
91
|
+
background: transparent;
|
|
92
|
+
cursor: pointer;
|
|
93
|
+
transition: 0.4s visibility, 0.4s opacity;
|
|
94
|
+
/* つらい */
|
|
95
|
+
/* このコンポーネントはCarouselでしか使われてないのでそっちでコンテキストで切る */
|
|
96
|
+
z-index: 1;
|
|
97
|
+
|
|
98
|
+
&:hover ${CarouselButtonIcon} {
|
|
99
|
+
background-color: ${({ theme }) =>
|
|
100
|
+
applyEffect(theme.color.surface4, theme.effect.hover)};
|
|
101
|
+
color: ${({ theme }) => applyEffect(theme.color.text5, theme.effect.hover)};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
&:active ${CarouselButtonIcon} {
|
|
105
|
+
background-color: ${({ theme }) =>
|
|
106
|
+
applyEffect(theme.color.surface4, theme.effect.press)};
|
|
107
|
+
color: ${({ theme }) => applyEffect(theme.color.text5, theme.effect.press)};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
${(p) =>
|
|
111
|
+
p.hide &&
|
|
112
|
+
css`
|
|
113
|
+
visibility: hidden;
|
|
114
|
+
opacity: 0;
|
|
115
|
+
pointer-events: none;
|
|
116
|
+
`}
|
|
117
|
+
`
|
|
118
|
+
|
|
119
|
+
export function ScrollHintButton({
|
|
120
|
+
direction,
|
|
121
|
+
onClick,
|
|
122
|
+
}: {
|
|
123
|
+
direction: Direction
|
|
124
|
+
onClick(): void
|
|
125
|
+
}) {
|
|
126
|
+
return (
|
|
127
|
+
<ScrollHintIcon onClick={onClick}>
|
|
128
|
+
<NextIcon
|
|
129
|
+
direction={
|
|
130
|
+
direction === Direction.Right
|
|
131
|
+
? WedgeDirection.Right
|
|
132
|
+
: // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
133
|
+
direction === Direction.Left
|
|
134
|
+
? WedgeDirection.Left
|
|
135
|
+
: unreachable()
|
|
136
|
+
}
|
|
137
|
+
/>
|
|
138
|
+
</ScrollHintIcon>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const ScrollHintIcon = styled(CarouselButtonIcon)`
|
|
143
|
+
cursor: pointer;
|
|
144
|
+
|
|
145
|
+
&:hover {
|
|
146
|
+
background-color: ${({ theme }) =>
|
|
147
|
+
applyEffect(theme.color.surface4, theme.effect.hover)};
|
|
148
|
+
color: ${({ theme }) => applyEffect(theme.color.text5, theme.effect.hover)};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
&:active {
|
|
152
|
+
background-color: ${({ theme }) =>
|
|
153
|
+
applyEffect(theme.color.surface4, theme.effect.press)};
|
|
154
|
+
color: ${({ theme }) => applyEffect(theme.color.text5, theme.effect.press)};
|
|
155
|
+
}
|
|
156
|
+
`
|
|
157
|
+
|
|
158
|
+
const onlyNonTouchDevice = css`
|
|
159
|
+
@media (hover: none) and (pointer: coarse) {
|
|
160
|
+
display: none;
|
|
161
|
+
}
|
|
162
|
+
`
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { action } from '@storybook/addon-actions'
|
|
2
|
+
import { boolean } from '@storybook/addon-knobs'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Link as RouterLink,
|
|
6
|
+
MemoryRouter as Router,
|
|
7
|
+
Route,
|
|
8
|
+
useParams,
|
|
9
|
+
useNavigate,
|
|
10
|
+
} from 'react-router-dom'
|
|
11
|
+
import Filter, { FilterButton, FilterLink } from '.'
|
|
12
|
+
import { ComponentAbstraction } from '@charcoal-ui/react'
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
title: 'Sandbox/Filter',
|
|
16
|
+
component: Filter,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const makeUrl = (page: number) => `/${page}`
|
|
20
|
+
|
|
21
|
+
export function Default() {
|
|
22
|
+
return (
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
24
|
+
// @ts-expect-error TOOD: adapt to react-router@6 (props should be covariant not bivariant)
|
|
25
|
+
<ComponentAbstraction components={{ Link: RouterLink }}>
|
|
26
|
+
<Router
|
|
27
|
+
initialEntries={Array.from({ length: 5 }).map((_, i) => makeUrl(i + 1))}
|
|
28
|
+
initialIndex={0}
|
|
29
|
+
>
|
|
30
|
+
<Route path="/:page" element={<FilterButtons />} />
|
|
31
|
+
</Router>
|
|
32
|
+
</ComponentAbstraction>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function FilterButtons() {
|
|
37
|
+
const navigate = useNavigate()
|
|
38
|
+
const params = useParams()
|
|
39
|
+
const page = params.page !== undefined ? parseInt(params.page, 10) : 0
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Filter>
|
|
43
|
+
<FilterLink to={makeUrl(0)} active={page === 0}>
|
|
44
|
+
新しい順
|
|
45
|
+
</FilterLink>
|
|
46
|
+
<FilterLink to={makeUrl(1)} active={page === 1}>
|
|
47
|
+
古い順
|
|
48
|
+
</FilterLink>
|
|
49
|
+
<FilterButton active={page === 2} onClick={() => navigate('2')}>
|
|
50
|
+
人気順
|
|
51
|
+
</FilterButton>
|
|
52
|
+
<FilterLink to={makeUrl(3)} active={page === 3}>
|
|
53
|
+
これはリンク
|
|
54
|
+
</FilterLink>
|
|
55
|
+
<FilterButton active={page === 4} onClick={() => navigate('4')}>
|
|
56
|
+
これはボタン
|
|
57
|
+
</FilterButton>
|
|
58
|
+
</Filter>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function Button() {
|
|
63
|
+
const active = boolean('Active', true)
|
|
64
|
+
const reactive = boolean('Reactive', false)
|
|
65
|
+
return (
|
|
66
|
+
<FilterButton onClick={action('click')} active={active} reactive={reactive}>
|
|
67
|
+
Label
|
|
68
|
+
</FilterButton>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function Link() {
|
|
73
|
+
const active = boolean('Active', true)
|
|
74
|
+
const reactive = boolean('Reactive', false)
|
|
75
|
+
return (
|
|
76
|
+
<FilterLink to="#" active={active} reactive={reactive}>
|
|
77
|
+
Label
|
|
78
|
+
</FilterLink>
|
|
79
|
+
)
|
|
80
|
+
}
|