@charcoal-ui/react 6.0.0-rc.3 → 6.0.0-rc.5
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/dist/_lib/useIsomorphicLayoutEffect.d.ts +6 -0
- package/dist/_lib/useIsomorphicLayoutEffect.d.ts.map +1 -0
- package/dist/components/Carousel/index.d.ts +2 -6
- package/dist/components/Carousel/index.d.ts.map +1 -1
- package/dist/components/Carousel/useCarouselScroller.d.ts.map +1 -1
- package/dist/components/Modal/index.d.ts.map +1 -1
- package/dist/components/Modal/useTransitionPresence.d.ts +19 -0
- package/dist/components/Modal/useTransitionPresence.d.ts.map +1 -0
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +100 -47
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/layered.css +100 -47
- package/dist/layered.css.map +1 -1
- package/package.json +5 -6
- package/src/__tests__/css-variables.test.ts +1 -1
- package/src/__tests__/token-v1-compat.test.ts +1 -2
- package/src/__tests__/token-v1-remap.test.ts +6 -6
- package/src/_lib/useIsomorphicLayoutEffect.ts +7 -0
- package/src/components/Carousel/MIGRATION.md +39 -39
- package/src/components/Carousel/__snapshots__/index.css.snap +39 -37
- package/src/components/Carousel/index.css +45 -44
- package/src/components/Carousel/index.story.tsx +87 -68
- package/src/components/Carousel/index.test.tsx +68 -47
- package/src/components/Carousel/index.tsx +23 -14
- package/src/components/Carousel/useCarouselScroller.ts +8 -12
- package/src/components/FieldLabel/__snapshots__/index.css.snap +1 -1
- package/src/components/FieldLabel/index.css +1 -1
- package/src/components/IconButton/__snapshots__/index.css.snap +1 -1
- package/src/components/IconButton/index.css +1 -1
- package/src/components/Modal/__snapshots__/index.css.snap +32 -1
- package/src/components/Modal/index.css +34 -0
- package/src/components/Modal/index.test.tsx +200 -0
- package/src/components/Modal/index.tsx +48 -78
- package/src/components/Modal/useTransitionPresence.ts +95 -0
- package/src/index.ts +0 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import './index.css'
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
Children,
|
|
4
5
|
forwardRef,
|
|
6
|
+
isValidElement,
|
|
5
7
|
memo,
|
|
6
8
|
useCallback,
|
|
7
9
|
useImperativeHandle,
|
|
@@ -23,11 +25,6 @@ import { useCarouselScroller } from './useCarouselScroller'
|
|
|
23
25
|
|
|
24
26
|
const getServerSnapshot = (): CarouselState => INITIAL_CAROUSEL_STATE
|
|
25
27
|
|
|
26
|
-
export type CarouselItem = {
|
|
27
|
-
id: string
|
|
28
|
-
children: ReactNode
|
|
29
|
-
}
|
|
30
|
-
|
|
31
28
|
export type ScrollAlign = 'left' | 'center' | 'right'
|
|
32
29
|
|
|
33
30
|
export type ScrollSnapType = 'none' | 'proximity' | 'mandatory'
|
|
@@ -70,7 +67,8 @@ export type CarouselProps = Readonly<{
|
|
|
70
67
|
onResize?: (width: number) => void
|
|
71
68
|
onScrollStateChange?: (canScroll: boolean) => void
|
|
72
69
|
defaultScroll?: { align?: ScrollAlign; offset?: number }
|
|
73
|
-
|
|
70
|
+
// 1 直接子要素 = 1 スライド(react-sandbox 互換)。
|
|
71
|
+
children: ReactNode
|
|
74
72
|
}>
|
|
75
73
|
|
|
76
74
|
type Direction = 'prev' | 'next'
|
|
@@ -149,6 +147,7 @@ const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
|
|
|
149
147
|
onResize,
|
|
150
148
|
onScrollStateChange,
|
|
151
149
|
defaultScroll: { align = 'left', offset = 0 } = {},
|
|
150
|
+
children,
|
|
152
151
|
...props
|
|
153
152
|
}: CarouselProps,
|
|
154
153
|
ref,
|
|
@@ -159,13 +158,20 @@ const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
|
|
|
159
158
|
const snapType = scrollSnap?.type ?? (size === 'S' ? 'mandatory' : 'none')
|
|
160
159
|
const snapAlign = scrollSnap?.align ?? 'center'
|
|
161
160
|
|
|
161
|
+
// 直接子要素 1 つを 1 スライドとして数える。key は子要素の key を引き継ぐ
|
|
162
|
+
// (toArray が付与する接頭辞付き key。無ければ index)。
|
|
163
|
+
const slides = Children.toArray(children)
|
|
164
|
+
const slideKeys = slides.map((slide, i) =>
|
|
165
|
+
isValidElement(slide) && slide.key != null ? slide.key : i,
|
|
166
|
+
)
|
|
167
|
+
|
|
162
168
|
const scrollerRef = useRef<HTMLDivElement>(null)
|
|
163
169
|
const [store] = useState(createCarouselStore)
|
|
164
170
|
|
|
165
171
|
const { scrollByStep, onItemResize, resetScroll } = useCarouselScroller(
|
|
166
172
|
scrollerRef,
|
|
167
173
|
store,
|
|
168
|
-
|
|
174
|
+
slides.length,
|
|
169
175
|
{ align, offset, scrollStep, onScroll, onResize, onScrollStateChange },
|
|
170
176
|
)
|
|
171
177
|
|
|
@@ -217,22 +223,25 @@ const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
|
|
|
217
223
|
aria-roledescription="carousel"
|
|
218
224
|
aria-label="Carousel"
|
|
219
225
|
>
|
|
220
|
-
|
|
226
|
+
{/* フォーカスリングは viewport に描く(理由は index.css の同セレクタ参照) */}
|
|
227
|
+
<div
|
|
228
|
+
className="charcoal-carousel__viewport"
|
|
229
|
+
data-focus-visible={scrollerFocusVisible || undefined}
|
|
230
|
+
>
|
|
221
231
|
<div
|
|
222
232
|
{...mergeProps(scrollerFocusProps, keyboardProps)}
|
|
223
233
|
ref={scrollerRef}
|
|
224
234
|
className="charcoal-carousel__scroller"
|
|
225
235
|
tabIndex={0}
|
|
226
|
-
data-focus-visible={scrollerFocusVisible || undefined}
|
|
227
236
|
>
|
|
228
|
-
{
|
|
237
|
+
{slides.map((slide, i) => (
|
|
229
238
|
<CarouselSlide
|
|
230
|
-
key={
|
|
239
|
+
key={slideKeys[i]}
|
|
231
240
|
index={i}
|
|
232
241
|
store={store}
|
|
233
242
|
onResize={onItemResize}
|
|
234
243
|
>
|
|
235
|
-
{
|
|
244
|
+
{slide}
|
|
236
245
|
</CarouselSlide>
|
|
237
246
|
))}
|
|
238
247
|
</div>
|
|
@@ -260,9 +269,9 @@ const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
|
|
|
260
269
|
data-visible={showIndicator}
|
|
261
270
|
aria-hidden={!showIndicator}
|
|
262
271
|
>
|
|
263
|
-
{
|
|
272
|
+
{slides.map((_, i) => (
|
|
264
273
|
<CarouselIndicatorItem
|
|
265
|
-
key={
|
|
274
|
+
key={slideKeys[i]}
|
|
266
275
|
index={i}
|
|
267
276
|
isActive={i === activeIndex}
|
|
268
277
|
onSelect={scrollToItem}
|
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
useEffect,
|
|
4
|
-
useLayoutEffect,
|
|
5
|
-
useRef,
|
|
6
|
-
type RefObject,
|
|
7
|
-
} from 'react'
|
|
1
|
+
import { useCallback, useEffect, useRef, type RefObject } from 'react'
|
|
2
|
+
import { useIsomorphicLayoutEffect } from '../../_lib/useIsomorphicLayoutEffect'
|
|
8
3
|
import type { CarouselStore } from './carouselStore'
|
|
9
4
|
import type { ScrollAlign, ScrollStep } from './index'
|
|
10
5
|
|
|
11
|
-
const useIsomorphicLayoutEffect =
|
|
12
|
-
typeof window !== 'undefined' ? useLayoutEffect : useEffect
|
|
13
|
-
|
|
14
6
|
const INTERACTION_EVENTS = ['pointerdown', 'wheel', 'touchstart'] as const
|
|
15
7
|
|
|
16
8
|
export type CarouselScrollerOptions = Readonly<{
|
|
@@ -74,8 +66,12 @@ export function useCarouselScroller(
|
|
|
74
66
|
left = maxScroll + offset
|
|
75
67
|
break
|
|
76
68
|
}
|
|
77
|
-
//
|
|
78
|
-
|
|
69
|
+
// scrollLeft 代入は CSS の scroll-behavior: smooth の対象になり
|
|
70
|
+
// 初期位置決めがアニメーションしてしまうため、instant で確定させる。
|
|
71
|
+
el.scrollTo({
|
|
72
|
+
left: Math.max(0, Math.min(left, maxScroll)),
|
|
73
|
+
behavior: 'instant',
|
|
74
|
+
})
|
|
79
75
|
}, [scrollerRef, align, offset])
|
|
80
76
|
|
|
81
77
|
// canPrev/canNext: scroll で更新。onScroll もここから発火。itemCount 変化で貼り直し。
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
|
|
55
55
|
.charcoal-icon-button[data-variant='Default'] {
|
|
56
56
|
color: var(--charcoal-color-icon-tertiary-default);
|
|
57
|
-
background-color:
|
|
57
|
+
background-color: transparent;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
.charcoal-icon-button[data-variant='Default'][data-active='true']:not(:disabled):not([aria-disabled]), .charcoal-icon-button[data-variant='Default'][data-active='true'][aria-disabled='false'] {
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
|
|
57
57
|
&[data-variant='Default'] {
|
|
58
58
|
color: var(--charcoal-color-icon-tertiary-default);
|
|
59
|
-
background-color:
|
|
59
|
+
background-color: transparent;
|
|
60
60
|
|
|
61
61
|
&[data-active='true']:not(:disabled):not([aria-disabled]),
|
|
62
62
|
&[data-active='true'][aria-disabled='false'] {
|
|
@@ -19,6 +19,38 @@
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/* easeOutQuart。duration は useTransitionPresence の MODAL_TRANSITION_DURATION_MS と一致させること。
|
|
23
|
+
transition は entering / exiting に限定し、開いたままテーマ切り替え等で
|
|
24
|
+
色や transform が変わってもアニメーションしないようにする */
|
|
25
|
+
|
|
26
|
+
.charcoal-modal-background[data-animation] {
|
|
27
|
+
overflow: hidden;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.charcoal-modal-background[data-animation='entering'], .charcoal-modal-background[data-animation='exiting'] {
|
|
31
|
+
transition: background-color 400ms cubic-bezier(0.25, 1, 0.5, 1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* 閉じアニメーション中はクリックを下のページへ通す(transitionend が来ない環境で
|
|
35
|
+
透明なオーバーレイがタップを奪い続けるのも防ぐ) */
|
|
36
|
+
|
|
37
|
+
.charcoal-modal-background[data-animation='exited'], .charcoal-modal-background[data-animation='exiting'] {
|
|
38
|
+
background-color: transparent;
|
|
39
|
+
pointer-events: none;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.charcoal-modal-background[data-animation='entered'] {
|
|
43
|
+
overflow: auto;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.charcoal-modal-background[data-animation='entering'] > .charcoal-modal-dialog, .charcoal-modal-background[data-animation='exiting'] > .charcoal-modal-dialog {
|
|
47
|
+
transition: transform 400ms cubic-bezier(0.25, 1, 0.5, 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.charcoal-modal-background[data-animation='exited'] > .charcoal-modal-dialog, .charcoal-modal-background[data-animation='exiting'] > .charcoal-modal-dialog {
|
|
51
|
+
transform: translateY(100%);
|
|
52
|
+
}
|
|
53
|
+
|
|
22
54
|
.charcoal-modal-close-button {
|
|
23
55
|
position: absolute;
|
|
24
56
|
top: 8px;
|
|
@@ -32,4 +64,3 @@
|
|
|
32
64
|
font-weight: inherit;
|
|
33
65
|
font-size: inherit;
|
|
34
66
|
}
|
|
35
|
-
|
|
@@ -20,6 +20,40 @@
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/* easeOutQuart。duration は useTransitionPresence の MODAL_TRANSITION_DURATION_MS と一致させること。
|
|
24
|
+
transition は entering / exiting に限定し、開いたままテーマ切り替え等で
|
|
25
|
+
色や transform が変わってもアニメーションしないようにする */
|
|
26
|
+
.charcoal-modal-background[data-animation] {
|
|
27
|
+
overflow: hidden;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.charcoal-modal-background[data-animation='entering'],
|
|
31
|
+
.charcoal-modal-background[data-animation='exiting'] {
|
|
32
|
+
transition: background-color 400ms cubic-bezier(0.25, 1, 0.5, 1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* 閉じアニメーション中はクリックを下のページへ通す(transitionend が来ない環境で
|
|
36
|
+
透明なオーバーレイがタップを奪い続けるのも防ぐ) */
|
|
37
|
+
.charcoal-modal-background[data-animation='exited'],
|
|
38
|
+
.charcoal-modal-background[data-animation='exiting'] {
|
|
39
|
+
background-color: transparent;
|
|
40
|
+
pointer-events: none;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.charcoal-modal-background[data-animation='entered'] {
|
|
44
|
+
overflow: auto;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.charcoal-modal-background[data-animation='entering'] > .charcoal-modal-dialog,
|
|
48
|
+
.charcoal-modal-background[data-animation='exiting'] > .charcoal-modal-dialog {
|
|
49
|
+
transition: transform 400ms cubic-bezier(0.25, 1, 0.5, 1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.charcoal-modal-background[data-animation='exited'] > .charcoal-modal-dialog,
|
|
53
|
+
.charcoal-modal-background[data-animation='exiting'] > .charcoal-modal-dialog {
|
|
54
|
+
transform: translateY(100%);
|
|
55
|
+
}
|
|
56
|
+
|
|
23
57
|
.charcoal-modal-close-button {
|
|
24
58
|
position: absolute;
|
|
25
59
|
top: 8px;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { render, fireEvent, act, cleanup } from '@testing-library/react'
|
|
3
|
+
import { OverlayProvider } from 'react-aria'
|
|
4
|
+
import { vi, describe, it, expect, afterEach } from 'vitest'
|
|
5
|
+
import Modal from '.'
|
|
6
|
+
import { MODAL_TRANSITION_DURATION_MS } from './useTransitionPresence'
|
|
7
|
+
|
|
8
|
+
const MOBILE_WIDTH = 500
|
|
9
|
+
const DESKTOP_WIDTH = 1024
|
|
10
|
+
|
|
11
|
+
function setWindowWidth(width: number) {
|
|
12
|
+
Object.defineProperty(window, 'innerWidth', {
|
|
13
|
+
writable: true,
|
|
14
|
+
configurable: true,
|
|
15
|
+
value: width,
|
|
16
|
+
})
|
|
17
|
+
fireEvent(window, new Event('resize'))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// jsdom は CSS transition を実行しないので transitionend を手動で発火させる
|
|
21
|
+
function fireTransitionEnd(el: Element, propertyName: string) {
|
|
22
|
+
fireEvent(
|
|
23
|
+
el,
|
|
24
|
+
Object.assign(new Event('transitionend', { bubbles: true }), {
|
|
25
|
+
propertyName,
|
|
26
|
+
}),
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getBackground() {
|
|
31
|
+
return document.querySelector('.charcoal-modal-background')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getDialog() {
|
|
35
|
+
return document.querySelector('.charcoal-modal-dialog')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function TestApp({ defaultOpen = false }: { defaultOpen?: boolean }) {
|
|
39
|
+
const [isOpen, setIsOpen] = useState(defaultOpen)
|
|
40
|
+
return (
|
|
41
|
+
<OverlayProvider>
|
|
42
|
+
<button data-testid="open" onClick={() => setIsOpen(true)}>
|
|
43
|
+
open
|
|
44
|
+
</button>
|
|
45
|
+
<button data-testid="close" onClick={() => setIsOpen(false)}>
|
|
46
|
+
close
|
|
47
|
+
</button>
|
|
48
|
+
<Modal
|
|
49
|
+
title="test modal"
|
|
50
|
+
isOpen={isOpen}
|
|
51
|
+
onClose={() => setIsOpen(false)}
|
|
52
|
+
bottomSheet
|
|
53
|
+
>
|
|
54
|
+
<div>modal content</div>
|
|
55
|
+
</Modal>
|
|
56
|
+
</OverlayProvider>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderModal({ defaultOpen = false } = {}) {
|
|
61
|
+
const result = render(<TestApp defaultOpen={defaultOpen} />)
|
|
62
|
+
const open = () =>
|
|
63
|
+
fireEvent.click(result.getByTestId('open', { exact: true }))
|
|
64
|
+
const close = () =>
|
|
65
|
+
fireEvent.click(result.getByTestId('close', { exact: true }))
|
|
66
|
+
return { open, close }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
cleanup()
|
|
71
|
+
vi.useRealTimers()
|
|
72
|
+
setWindowWidth(DESKTOP_WIDTH)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('Modal (bottom sheet transition)', () => {
|
|
76
|
+
it('desktop: mounts and unmounts immediately without data-animation', () => {
|
|
77
|
+
setWindowWidth(DESKTOP_WIDTH)
|
|
78
|
+
const { open, close } = renderModal()
|
|
79
|
+
|
|
80
|
+
expect(getDialog()).not.toBeInTheDocument()
|
|
81
|
+
|
|
82
|
+
open()
|
|
83
|
+
expect(getDialog()).toBeInTheDocument()
|
|
84
|
+
expect(getBackground()).not.toHaveAttribute('data-animation')
|
|
85
|
+
|
|
86
|
+
close()
|
|
87
|
+
expect(getDialog()).not.toBeInTheDocument()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('mobile: enters via transition and settles on the dialog transform transitionend only', () => {
|
|
91
|
+
setWindowWidth(MOBILE_WIDTH)
|
|
92
|
+
const { open } = renderModal()
|
|
93
|
+
|
|
94
|
+
open()
|
|
95
|
+
expect(getDialog()).toBeInTheDocument()
|
|
96
|
+
expect(getBackground()).toHaveAttribute('data-animation', 'entering')
|
|
97
|
+
|
|
98
|
+
// 背景の background-color の transitionend では entered にならない
|
|
99
|
+
fireTransitionEnd(getBackground() as Element, 'background-color')
|
|
100
|
+
expect(getBackground()).toHaveAttribute('data-animation', 'entering')
|
|
101
|
+
|
|
102
|
+
fireTransitionEnd(getDialog() as Element, 'transform')
|
|
103
|
+
expect(getBackground()).toHaveAttribute('data-animation', 'entered')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('mobile: keeps the DOM mounted while exiting, unmounts after transitionend', () => {
|
|
107
|
+
setWindowWidth(MOBILE_WIDTH)
|
|
108
|
+
const { open, close } = renderModal()
|
|
109
|
+
|
|
110
|
+
open()
|
|
111
|
+
fireTransitionEnd(getDialog() as Element, 'transform')
|
|
112
|
+
|
|
113
|
+
close()
|
|
114
|
+
expect(getBackground()).toHaveAttribute('data-animation', 'exiting')
|
|
115
|
+
expect(getDialog()).toBeInTheDocument()
|
|
116
|
+
|
|
117
|
+
fireTransitionEnd(getDialog() as Element, 'transform')
|
|
118
|
+
expect(getDialog()).not.toBeInTheDocument()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('mobile: closing mid-enter goes straight to exiting without unmounting', () => {
|
|
122
|
+
setWindowWidth(MOBILE_WIDTH)
|
|
123
|
+
const { open, close } = renderModal()
|
|
124
|
+
|
|
125
|
+
open()
|
|
126
|
+
expect(getBackground()).toHaveAttribute('data-animation', 'entering')
|
|
127
|
+
|
|
128
|
+
// enter の transitionend を待たずに閉じる
|
|
129
|
+
close()
|
|
130
|
+
expect(getBackground()).toHaveAttribute('data-animation', 'exiting')
|
|
131
|
+
expect(getDialog()).toBeInTheDocument()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('mobile: reopening mid-exit goes straight to entering, the element never leaves the document', () => {
|
|
135
|
+
setWindowWidth(MOBILE_WIDTH)
|
|
136
|
+
const { open, close } = renderModal()
|
|
137
|
+
|
|
138
|
+
open()
|
|
139
|
+
fireTransitionEnd(getDialog() as Element, 'transform')
|
|
140
|
+
const dialog = getDialog()
|
|
141
|
+
|
|
142
|
+
close()
|
|
143
|
+
expect(getBackground()).toHaveAttribute('data-animation', 'exiting')
|
|
144
|
+
|
|
145
|
+
// exit の transitionend を待たずに開き直す
|
|
146
|
+
open()
|
|
147
|
+
expect(getBackground()).toHaveAttribute('data-animation', 'entering')
|
|
148
|
+
// 同じ要素のまま(アンマウント → 再マウントが起きていない)
|
|
149
|
+
expect(getDialog()).toBe(dialog)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('mobile: a stale transitionend after reopening settles to entered instead of unmounting', () => {
|
|
153
|
+
setWindowWidth(MOBILE_WIDTH)
|
|
154
|
+
const { open, close } = renderModal()
|
|
155
|
+
|
|
156
|
+
open()
|
|
157
|
+
fireTransitionEnd(getDialog() as Element, 'transform')
|
|
158
|
+
close()
|
|
159
|
+
open()
|
|
160
|
+
expect(getBackground()).toHaveAttribute('data-animation', 'entering')
|
|
161
|
+
|
|
162
|
+
// 開き直しの直後に、キャンセルされた exit 側の transitionend が届く
|
|
163
|
+
fireTransitionEnd(getDialog() as Element, 'transform')
|
|
164
|
+
expect(getDialog()).toBeInTheDocument()
|
|
165
|
+
expect(getBackground()).toHaveAttribute('data-animation', 'entered')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('mobile: unmounts via the timeout fallback when transitionend never fires', () => {
|
|
169
|
+
vi.useFakeTimers()
|
|
170
|
+
setWindowWidth(MOBILE_WIDTH)
|
|
171
|
+
const { open, close } = renderModal()
|
|
172
|
+
|
|
173
|
+
open()
|
|
174
|
+
fireTransitionEnd(getDialog() as Element, 'transform')
|
|
175
|
+
|
|
176
|
+
close()
|
|
177
|
+
expect(getDialog()).toBeInTheDocument()
|
|
178
|
+
|
|
179
|
+
act(() => {
|
|
180
|
+
vi.advanceTimersByTime(MODAL_TRANSITION_DURATION_MS + 100)
|
|
181
|
+
})
|
|
182
|
+
expect(getDialog()).not.toBeInTheDocument()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('resizing to desktop while open drops the animation, then closes immediately', () => {
|
|
186
|
+
setWindowWidth(MOBILE_WIDTH)
|
|
187
|
+
const { open, close } = renderModal()
|
|
188
|
+
|
|
189
|
+
open()
|
|
190
|
+
fireTransitionEnd(getDialog() as Element, 'transform')
|
|
191
|
+
expect(getBackground()).toHaveAttribute('data-animation', 'entered')
|
|
192
|
+
|
|
193
|
+
setWindowWidth(DESKTOP_WIDTH)
|
|
194
|
+
expect(getDialog()).toBeInTheDocument()
|
|
195
|
+
expect(getBackground()).not.toHaveAttribute('data-animation')
|
|
196
|
+
|
|
197
|
+
close()
|
|
198
|
+
expect(getDialog()).not.toBeInTheDocument()
|
|
199
|
+
})
|
|
200
|
+
})
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useContext, forwardRef, memo } from 'react'
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import type { AriaDialogProps } from 'react-aria/useDialog'
|
|
4
|
-
import { animated, useTransition, easings } from '@react-spring/web'
|
|
5
4
|
import Button, { ButtonProps } from '../Button'
|
|
6
5
|
import IconButton, { IconButtonProps } from '../IconButton'
|
|
7
6
|
import { Dialog } from './Dialog'
|
|
@@ -11,6 +10,7 @@ import {
|
|
|
11
10
|
useCharcoalModalOverlay,
|
|
12
11
|
useWindowWidth,
|
|
13
12
|
} from './useCustomModalOverlay'
|
|
13
|
+
import { useTransitionPresence } from './useTransitionPresence'
|
|
14
14
|
|
|
15
15
|
import './index.css'
|
|
16
16
|
|
|
@@ -101,31 +101,11 @@ const Modal = forwardRef<HTMLDivElement, ModalProps>(function ModalInner(
|
|
|
101
101
|
const transitionEnabled = isMobile && bottomSheet !== false
|
|
102
102
|
const showDismiss = !isMobile || bottomSheet !== true
|
|
103
103
|
|
|
104
|
-
const transition = useTransition(isOpen, {
|
|
105
|
-
from: {
|
|
106
|
-
transform: 'translateY(100%)',
|
|
107
|
-
backgroundColor: 'rgba(0, 0, 0, 0)',
|
|
108
|
-
overflow: 'hidden',
|
|
109
|
-
},
|
|
110
|
-
enter: {
|
|
111
|
-
transform: 'translateY(0%)',
|
|
112
|
-
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
|
113
|
-
},
|
|
114
|
-
update: {
|
|
115
|
-
overflow: 'auto',
|
|
116
|
-
},
|
|
117
|
-
leave: {
|
|
118
|
-
transform: 'translateY(100%)',
|
|
119
|
-
backgroundColor: 'rgba(0, 0, 0, 0)',
|
|
120
|
-
overflow: 'hidden',
|
|
121
|
-
},
|
|
122
|
-
config: transitionEnabled
|
|
123
|
-
? { duration: 400, easing: easings.easeOutQuart }
|
|
124
|
-
: { duration: 0 },
|
|
125
|
-
})
|
|
126
|
-
|
|
127
104
|
const bgRef = React.useRef<HTMLDivElement>(null)
|
|
128
105
|
|
|
106
|
+
const { isPresent, animationState, handleTransitionEnd } =
|
|
107
|
+
useTransitionPresence(isOpen, transitionEnabled, bgRef, ref)
|
|
108
|
+
|
|
129
109
|
const handleClick = React.useCallback(
|
|
130
110
|
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
|
131
111
|
if (e.currentTarget === e.target) {
|
|
@@ -135,64 +115,54 @@ const Modal = forwardRef<HTMLDivElement, ModalProps>(function ModalInner(
|
|
|
135
115
|
[onClose],
|
|
136
116
|
)
|
|
137
117
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
118
|
+
if (!isPresent) {
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<Overlay portalContainer={portalContainer}>
|
|
124
|
+
<div
|
|
125
|
+
className="charcoal-modal-background"
|
|
126
|
+
ref={bgRef}
|
|
127
|
+
{...underlayProps}
|
|
128
|
+
style={{ zIndex }}
|
|
129
|
+
data-bottom-sheet={bottomSheet}
|
|
130
|
+
data-animation={transitionEnabled ? animationState : undefined}
|
|
131
|
+
onClick={handleClick}
|
|
132
|
+
onTransitionEnd={handleTransitionEnd}
|
|
133
|
+
>
|
|
134
|
+
<ModalBackgroundContext.Provider value={bgRef.current}>
|
|
135
|
+
<Dialog
|
|
136
|
+
ref={ref}
|
|
137
|
+
{...modalProps}
|
|
138
|
+
size={size}
|
|
139
|
+
bottomSheet={bottomSheet}
|
|
140
|
+
className={className}
|
|
156
141
|
>
|
|
157
|
-
<
|
|
158
|
-
{
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
{children}
|
|
179
|
-
{isDismissable === true && (
|
|
180
|
-
<ModalCloseButton
|
|
181
|
-
aria-label={closeButtonAriaLabel}
|
|
182
|
-
onClick={onClose}
|
|
183
|
-
/>
|
|
184
|
-
)}
|
|
185
|
-
</ModalContext.Provider>
|
|
186
|
-
</AnimatedDialog>
|
|
187
|
-
</ModalBackgroundContext.Provider>
|
|
188
|
-
</animated.div>
|
|
189
|
-
</Overlay>
|
|
190
|
-
),
|
|
142
|
+
<ModalContext.Provider
|
|
143
|
+
value={{
|
|
144
|
+
titleProps: {},
|
|
145
|
+
title,
|
|
146
|
+
close: onClose,
|
|
147
|
+
showDismiss,
|
|
148
|
+
bottomSheet,
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
{children}
|
|
152
|
+
{isDismissable === true && (
|
|
153
|
+
<ModalCloseButton
|
|
154
|
+
aria-label={closeButtonAriaLabel}
|
|
155
|
+
onClick={onClose}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
</ModalContext.Provider>
|
|
159
|
+
</Dialog>
|
|
160
|
+
</ModalBackgroundContext.Provider>
|
|
161
|
+
</div>
|
|
162
|
+
</Overlay>
|
|
191
163
|
)
|
|
192
164
|
})
|
|
193
165
|
|
|
194
|
-
const AnimatedDialog = animated(Dialog)
|
|
195
|
-
|
|
196
166
|
export default memo(Modal)
|
|
197
167
|
|
|
198
168
|
export const ModalContext = React.createContext<{
|