@charcoal-ui/react 6.0.0-rc.4 → 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__/token-v1-compat.test.ts +0 -1
- 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
|
@@ -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<{
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useState,
|
|
5
|
+
RefObject,
|
|
6
|
+
TransitionEvent,
|
|
7
|
+
} from 'react'
|
|
8
|
+
import { useIsomorphicLayoutEffect } from '../../_lib/useIsomorphicLayoutEffect'
|
|
9
|
+
|
|
10
|
+
// index.css の transition-duration と一致させること
|
|
11
|
+
export const MODAL_TRANSITION_DURATION_MS = 400
|
|
12
|
+
|
|
13
|
+
type AnimationState = 'exited' | 'entering' | 'entered' | 'exiting'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* アニメーション終了時の状態遷移。
|
|
17
|
+
* 途中で open/close が切り替わって state が既に進んでいた場合は何もしない
|
|
18
|
+
*/
|
|
19
|
+
function settle(state: AnimationState): AnimationState {
|
|
20
|
+
if (state === 'entering') return 'entered'
|
|
21
|
+
if (state === 'exiting') return 'exited'
|
|
22
|
+
return state
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* CSS transition によるモーダルの開閉アニメーションを管理する。
|
|
27
|
+
* exit アニメーションが終わるまで DOM をマウントしたままにする
|
|
28
|
+
*
|
|
29
|
+
* @param isOpen モーダルを開くべきか
|
|
30
|
+
* @param enabled アニメーションを行うか(false なら即座にマウント・アンマウント)
|
|
31
|
+
* @param backgroundRef 背景要素。マウント直後の layout flush に使う
|
|
32
|
+
* @param dialogRef transform をアニメーションさせる要素(ダイアログ)
|
|
33
|
+
*/
|
|
34
|
+
export function useTransitionPresence(
|
|
35
|
+
isOpen: boolean,
|
|
36
|
+
enabled: boolean,
|
|
37
|
+
backgroundRef: RefObject<HTMLElement>,
|
|
38
|
+
dialogRef: RefObject<HTMLElement>,
|
|
39
|
+
) {
|
|
40
|
+
// 開いた状態で初回マウントされた場合もアニメーションさせるため常に 'exited' から始める。
|
|
41
|
+
// enabled でない場合は下の layout effect が描画前に 'entered' へ進める
|
|
42
|
+
const [state, setState] = useState<AnimationState>('exited')
|
|
43
|
+
|
|
44
|
+
useIsomorphicLayoutEffect(() => {
|
|
45
|
+
if (!enabled) {
|
|
46
|
+
setState(isOpen ? 'entered' : 'exited')
|
|
47
|
+
} else if (isOpen) {
|
|
48
|
+
// 閉じた状態のスタイルを確定させ、enter の transition が発火するようにする。
|
|
49
|
+
// dialogRef は子コンポーネントの passive effect で張られるためこの時点では
|
|
50
|
+
// まだ古い/null のことがあり、同じ commit で同期的に張られる背景要素を読む
|
|
51
|
+
if (backgroundRef.current) void backgroundRef.current.offsetHeight
|
|
52
|
+
setState((s) => (s === 'exited' || s === 'exiting' ? 'entering' : s))
|
|
53
|
+
} else {
|
|
54
|
+
setState((s) => (s === 'exited' ? s : 'exiting'))
|
|
55
|
+
}
|
|
56
|
+
}, [isOpen, enabled, backgroundRef])
|
|
57
|
+
|
|
58
|
+
// safety net: transitionend が来なくても(トランジションのキャンセル、
|
|
59
|
+
// reduced-motion、バックグラウンドタブ等)モーダルが永遠に残らないようにする
|
|
60
|
+
const isAnimating = state === 'entering' || state === 'exiting'
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!isAnimating) return
|
|
63
|
+
// transitionend と同時刻に発火して競合しないよう少し遅らせる
|
|
64
|
+
const timer = setTimeout(
|
|
65
|
+
() => setState(settle),
|
|
66
|
+
MODAL_TRANSITION_DURATION_MS + 100,
|
|
67
|
+
)
|
|
68
|
+
return () => clearTimeout(timer)
|
|
69
|
+
}, [isAnimating, state])
|
|
70
|
+
|
|
71
|
+
// 背景 div に付ける。ダイアログの transform の transitionend だけが
|
|
72
|
+
// 開閉アニメーションの終了を意味する(他のトランジションもバブルしてくるため絞り込む)。
|
|
73
|
+
// propertyName は index.css がアニメーションさせるプロパティと一致させること。
|
|
74
|
+
// なお enter 完了直後の close では、完了済み enter の transitionend が
|
|
75
|
+
// 'exiting' へ遷移した後に届いて exit アニメーションが省略される可能性が
|
|
76
|
+
// 理論上あるが、サブフレーム幅のレースで実害は見た目のみのため許容している
|
|
77
|
+
const handleTransitionEnd = useCallback(
|
|
78
|
+
(e: TransitionEvent<HTMLElement>) => {
|
|
79
|
+
const isDialogSlide =
|
|
80
|
+
e.target === dialogRef.current && e.propertyName === 'transform'
|
|
81
|
+
if (isDialogSlide) {
|
|
82
|
+
setState(settle)
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
[dialogRef],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
// enabled でないときは isOpen だけで即座にマウント・アンマウントする
|
|
90
|
+
// (state 経由にすると close 時に開いたツリーを丸ごと 1 回余計に描画する)
|
|
91
|
+
isPresent: enabled ? isOpen || state !== 'exited' : isOpen,
|
|
92
|
+
animationState: state,
|
|
93
|
+
handleTransitionEnd,
|
|
94
|
+
}
|
|
95
|
+
}
|