| 1 | import React, { |
| 2 | ViewTransition, |
| 3 | Suspense, |
| 4 | use, |
| 5 | useState, |
| 6 | useOptimistic, |
| 7 | startTransition, |
| 8 | addTransitionType, |
| 9 | } from 'react'; |
| 10 | import SwipeRecognizer from './SwipeRecognizer.js'; |
| 11 | import './NestedParentExit.css'; |
| 12 | |
| 13 | const items = [ |
| 14 | {id: 1, title: 'First Post', body: 'Hello from the first post.'}, |
| 15 | {id: 2, title: 'Second Post', body: 'Hello from the second post.'}, |
| 16 | {id: 3, title: 'Third Post', body: 'Hello from the third post.'}, |
| 17 | ]; |
| 18 | |
| 19 | let feedRevealPromise = null; |
| 20 | |
| 21 | export function resetFeedReveal() { |
| 22 | feedRevealPromise = null; |
| 23 | } |
| 24 | |
| 25 | function FeedReveal() { |
| 26 | if (feedRevealPromise === null) { |
| 27 | feedRevealPromise = new Promise(resolve => setTimeout(resolve, 1000)); |
| 28 | } |
| 29 | use(feedRevealPromise); |
| 30 | return null; |
| 31 | } |
| 32 | |
| 33 | function FeedSkeleton() { |
| 34 | return ( |
| 35 | <ViewTransition exit="nested-feed-exit"> |
| 36 | <div className="nested-feed"> |
| 37 | {items.map(item => ( |
| 38 | // Mirrors FeedItem: each skeleton row relays the exit (level 1), and |
| 39 | // its title/body placeholders relay again (level 2) so they animate |
| 40 | // out separately from the row — up and to the right. This exercises |
| 41 | // the nested parentExit relay through the SSR streaming reveal. |
| 42 | <ViewTransition key={item.id} parentExit="nested-exit-left"> |
| 43 | <div className="feed-item feed-item-skeleton"> |
| 44 | <ViewTransition parentExit="nested-title-exit-up"> |
| 45 | <div className="feed-item-title skeleton-line skeleton-title" /> |
| 46 | </ViewTransition> |
| 47 | <ViewTransition parentExit="nested-body-exit-right"> |
| 48 | <div className="skeleton-line skeleton-body" /> |
| 49 | </ViewTransition> |
| 50 | </div> |
| 51 | </ViewTransition> |
| 52 | ))} |
| 53 | </div> |
| 54 | </ViewTransition> |
| 55 | ); |
| 56 | } |
| 57 | |
| 58 | function logGestureParent(kind, title, _timeline, _options, _instance, types) { |
| 59 | // eslint-disable-next-line no-console |
| 60 | console.log(`[NestedParentExit] onGestureParent${kind}`, title, types); |
| 61 | } |
| 62 | |
| 63 | function FeedItem({item, index, activeIndex, onSelect}) { |
| 64 | const isActive = activeIndex === index; |
| 65 | |
| 66 | return ( |
| 67 | <ViewTransition |
| 68 | name={'nested-post-' + item.id} |
| 69 | share={{ |
| 70 | 'nav-forward': 'nested-shared-post-forward', |
| 71 | 'nav-back': 'nested-shared-post-back', |
| 72 | }} |
| 73 | parentExit={isActive ? undefined : 'nested-exit-left'} |
| 74 | parentEnter={isActive ? undefined : 'nested-enter-from-left'} |
| 75 | onGestureParentExit={(...args) => |
| 76 | logGestureParent('Exit', item.title, ...args) |
| 77 | } |
| 78 | onGestureParentEnter={(...args) => |
| 79 | logGestureParent('Enter', item.title, ...args) |
| 80 | }> |
| 81 | <div className="feed-item" onClick={() => onSelect(item, index)}> |
| 82 | <ViewTransition |
| 83 | name={'nested-title-' + item.id} |
| 84 | share={{ |
| 85 | 'nav-forward': 'nested-shared-inner-forward', |
| 86 | 'nav-back': 'nested-shared-inner-back', |
| 87 | }} |
| 88 | parentExit={isActive ? undefined : 'nested-title-exit-up'} |
| 89 | parentEnter={isActive ? undefined : 'nested-title-enter-from-up'}> |
| 90 | <div className="feed-item-title">{item.title}</div> |
| 91 | </ViewTransition> |
| 92 | <ViewTransition |
| 93 | parentExit={isActive ? undefined : 'nested-body-exit-right'} |
| 94 | parentEnter={isActive ? undefined : 'nested-body-enter-from-right'}> |
| 95 | <p>{item.body}</p> |
| 96 | </ViewTransition> |
| 97 | </div> |
| 98 | </ViewTransition> |
| 99 | ); |
| 100 | } |
| 101 | |
| 102 | function Detail({item, onBack}) { |
| 103 | return ( |
| 104 | <ViewTransition |
| 105 | name={'nested-post-' + item.id} |
| 106 | share={{ |
| 107 | 'nav-forward': 'nested-shared-post-forward', |
| 108 | 'nav-back': 'nested-shared-post-back', |
| 109 | }}> |
| 110 | <div className="detail-view"> |
| 111 | <ViewTransition |
| 112 | enter="nested-back-btn-enter" |
| 113 | exit="nested-back-btn-exit"> |
| 114 | <button className="back-button" onClick={onBack}> |
| 115 | ← Back |
| 116 | </button> |
| 117 | </ViewTransition> |
| 118 | <ViewTransition |
| 119 | name={'nested-title-' + item.id} |
| 120 | share={{ |
| 121 | 'nav-forward': 'nested-shared-inner-forward', |
| 122 | 'nav-back': 'nested-shared-inner-back', |
| 123 | }}> |
| 124 | <div className="feed-item-title">{item.title}</div> |
| 125 | </ViewTransition> |
| 126 | <p>{item.body}</p> |
| 127 | </div> |
| 128 | </ViewTransition> |
| 129 | ); |
| 130 | } |
| 131 | |
| 132 | const initialNav = {selected: null, activeIndex: null}; |
| 133 | |
| 134 | export default function NestedParentExit() { |
| 135 | const [nav, setNav] = useState(initialNav); |
| 136 | const [optimisticNav, navigateByGesture] = useOptimistic( |
| 137 | nav, |
| 138 | (state, direction) => { |
| 139 | if (direction === 'left' && state.selected === null) { |
| 140 | return {selected: items[0], activeIndex: 0}; |
| 141 | } |
| 142 | if (direction === 'right' && state.selected !== null) { |
| 143 | return { |
| 144 | selected: null, |
| 145 | activeIndex: |
| 146 | state.activeIndex ?? |
| 147 | items.findIndex(i => i.id === state.selected.id), |
| 148 | }; |
| 149 | } |
| 150 | return state; |
| 151 | } |
| 152 | ); |
| 153 | |
| 154 | const {selected, activeIndex} = optimisticNav; |
| 155 | |
| 156 | function goToDetail(item, index) { |
| 157 | startTransition(() => { |
| 158 | addTransitionType('nav-forward'); |
| 159 | setNav({selected: item, activeIndex: index}); |
| 160 | }); |
| 161 | } |
| 162 | |
| 163 | function goBack() { |
| 164 | const current = selected; |
| 165 | if (current == null) { |
| 166 | return; |
| 167 | } |
| 168 | const backIndex = items.findIndex(i => i.id === current.id); |
| 169 | startTransition(() => { |
| 170 | addTransitionType('nav-back'); |
| 171 | setNav({selected: null, activeIndex: backIndex}); |
| 172 | }); |
| 173 | } |
| 174 | |
| 175 | function swipeAction() { |
| 176 | if (nav.selected === null) { |
| 177 | goToDetail(items[0], 0); |
| 178 | } else { |
| 179 | goBack(); |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | return ( |
| 184 | <div className="nested-parent-exit"> |
| 185 | <p className="nested-parent-exit-label"> |
| 186 | Parent Exit/Enter — click a post or swipe (scroll the strip below) |
| 187 | </p> |
| 188 | <div className="nested-parent-exit-swipe swipe-recognizer"> |
| 189 | <SwipeRecognizer |
| 190 | action={swipeAction} |
| 191 | gesture={direction => { |
| 192 | addTransitionType( |
| 193 | direction === 'left' ? 'nav-forward' : 'nav-back' |
| 194 | ); |
| 195 | navigateByGesture(direction); |
| 196 | }} |
| 197 | direction={selected ? 'right' : 'left'}> |
| 198 | <ViewTransition key={selected ? 'detail' : 'feed'} update="none"> |
| 199 | <div className="nested-parent-exit-panel"> |
| 200 | {selected ? ( |
| 201 | <Detail item={selected} onBack={goBack} /> |
| 202 | ) : ( |
| 203 | <Suspense fallback={<FeedSkeleton />}> |
| 204 | {/* The entering ViewTransition is the direct child of the |
| 205 | Suspense content, so it activates an enter scope and the |
| 206 | feed items below relay parentEnter (emitting |
| 207 | vt-parent-enter annotations in Fizz). */} |
| 208 | <ViewTransition enter="nested-feed-enter"> |
| 209 | <div className="nested-feed"> |
| 210 | {items.map((item, index) => ( |
| 211 | <FeedItem |
| 212 | key={item.id} |
| 213 | item={item} |
| 214 | index={index} |
| 215 | activeIndex={activeIndex} |
| 216 | onSelect={goToDetail} |
| 217 | /> |
| 218 | ))} |
| 219 | <FeedReveal /> |
| 220 | </div> |
| 221 | </ViewTransition> |
| 222 | </Suspense> |
| 223 | )} |
| 224 | </div> |
| 225 | </ViewTransition> |
| 226 | </SwipeRecognizer> |
| 227 | </div> |
| 228 | </div> |
| 229 | ); |
| 230 | } |