@tamagui/sheet 1.0.1-beta.100 → 1.0.1-beta.103

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/src/Sheet.tsx CHANGED
@@ -1,29 +1,83 @@
1
- import { isSSR, isWeb, styled, themeable, withStaticProperties } from '@tamagui/core'
1
+ import {
2
+ GetProps,
3
+ Theme,
4
+ isClient,
5
+ isWeb,
6
+ mergeEvent,
7
+ styled,
8
+ themeable,
9
+ useEvent,
10
+ useIsomorphicLayoutEffect,
11
+ useThemeName,
12
+ withStaticProperties,
13
+ } from '@tamagui/core'
2
14
  import { ScopedProps, createContextScope } from '@tamagui/create-context'
15
+ import { Portal } from '@tamagui/portal'
3
16
  import { XStack, XStackProps, YStack } from '@tamagui/stacks'
4
17
  import { useControllableState } from '@tamagui/use-controllable-state'
5
18
  import React, {
6
19
  ReactNode,
20
+ createContext,
7
21
  forwardRef,
8
22
  isValidElement,
9
- useCallback,
23
+ useContext,
10
24
  useEffect,
11
25
  useLayoutEffect,
12
26
  useMemo,
13
27
  useRef,
14
28
  useState,
15
29
  } from 'react'
16
- import { Animated, LayoutRectangle, PanResponder, View } from 'react-native'
30
+ import {
31
+ Animated,
32
+ GestureResponderEvent,
33
+ PanResponder,
34
+ PanResponderGestureState,
35
+ View,
36
+ } from 'react-native'
37
+
38
+ const SHEET_NAME = 'Sheet'
39
+ const SHEET_HANDLE_NAME = 'SheetHandle'
40
+
41
+ export type SheetProps = ScopedProps<
42
+ {
43
+ open?: boolean
44
+ defaultOpen?: boolean
45
+ onChangeOpen?: OpenChangeHandler
46
+ position?: number
47
+ defaultPosition?: number
48
+ snapPoints?: number[]
49
+ onChangePosition?: PositionChangeHandler
50
+ children?: ReactNode
51
+ dismissOnOverlayPress?: boolean
52
+ animationConfig?: Animated.SpringAnimationConfig
53
+ disableDrag?: boolean
54
+ modal?: boolean
55
+ },
56
+ 'Sheet'
57
+ >
17
58
 
18
59
  type PositionChangeHandler =
19
60
  | ((position: number) => void)
20
61
  | React.Dispatch<React.SetStateAction<number>>
21
62
 
22
- const DRAWER_NAME = 'Drawer'
23
- const DRAWER_HANDLE_NAME = 'DrawerHandle'
63
+ type OpenChangeHandler = ((open: boolean) => void) | React.Dispatch<React.SetStateAction<boolean>>
64
+
65
+ type SheetContextValue = Required<
66
+ Pick<SheetProps, 'open' | 'position' | 'snapPoints' | 'dismissOnOverlayPress'>
67
+ > & {
68
+ hidden: boolean
69
+ setPosition: React.Dispatch<React.SetStateAction<number>>
70
+ setOpen: React.Dispatch<React.SetStateAction<boolean>>
71
+ }
24
72
 
25
- export const DrawerHandleFrame = styled(XStack, {
26
- name: DRAWER_HANDLE_NAME,
73
+ const [createSheetContext, createSheetScope] = createContextScope(SHEET_NAME)
74
+ const [SheetProvider, useSheetContext] = createSheetContext<SheetContextValue>(
75
+ SHEET_NAME,
76
+ {} as any
77
+ )
78
+
79
+ export const SheetHandleFrame = styled(XStack, {
80
+ name: SHEET_HANDLE_NAME,
27
81
  height: 10,
28
82
  borderRadius: 100,
29
83
  backgroundColor: '$background',
@@ -41,12 +95,18 @@ export const DrawerHandleFrame = styled(XStack, {
41
95
  },
42
96
  })
43
97
 
44
- export const DrawerHandle = DrawerHandleFrame.extractable(
45
- ({ __scopeDrawer, ...props }: ScopedProps<XStackProps, 'Drawer'>) => {
46
- const context = useDrawerContext(DRAWER_HANDLE_NAME, __scopeDrawer)
98
+ type SheetScopedProps<A> = ScopedProps<A, 'Sheet'>
99
+
100
+ export const SheetHandle = SheetHandleFrame.extractable(
101
+ ({ __scopeSheet, ...props }: SheetScopedProps<XStackProps>) => {
102
+ const context = useSheetContext(SHEET_HANDLE_NAME, __scopeSheet)
103
+
104
+ if (context.open === false) {
105
+ return null
106
+ }
47
107
 
48
108
  return (
49
- <DrawerHandleFrame
109
+ <SheetHandleFrame
50
110
  onPress={() => {
51
111
  const nextPos = (context.position + 1) % context.snapPoints.length
52
112
  context.setPosition(nextPos)
@@ -57,145 +117,231 @@ export const DrawerHandle = DrawerHandleFrame.extractable(
57
117
  }
58
118
  )
59
119
 
60
- type DrawerContextValue = {
61
- position: number
62
- snapPoints: number[]
63
- setPosition: React.Dispatch<React.SetStateAction<number>>
64
- }
65
-
66
- const [createDrawerContext, createDrawerScope] = createContextScope(DRAWER_NAME)
67
- const [DrawerProvider, useDrawerContext] = createDrawerContext<DrawerContextValue>(
68
- DRAWER_NAME,
69
- {} as any
70
- )
71
-
72
- export const DrawerBackdrop = styled(YStack, {
73
- name: 'DrawerBackdrop',
120
+ export const SheetOverlayFrame = styled(YStack, {
121
+ name: 'SheetOverlay',
122
+ // TODO this should be $background without opacity and just customized by theme
74
123
  backgroundColor: '$color',
75
124
  fullscreen: true,
76
125
  opacity: 0.2,
126
+ zIndex: 0,
127
+
128
+ variants: {
129
+ closed: {
130
+ true: {
131
+ opacity: 0,
132
+ pointerEvents: 'none',
133
+ },
134
+ false: {
135
+ pointerEvents: 'auto',
136
+ },
137
+ // TODO still have as const bug
138
+ } as const,
139
+ },
77
140
  })
78
141
 
79
- export const DrawerFrame = styled(YStack, {
80
- name: 'DrawerFrame',
142
+ export type SheetOverlayProps = GetProps<typeof SheetOverlayFrame>
143
+
144
+ export const SheetOverlay = SheetOverlayFrame.extractable(
145
+ ({ __scopeSheet, ...props }: SheetScopedProps<SheetOverlayProps>) => {
146
+ const context = useSheetContext(SHEET_HANDLE_NAME, __scopeSheet)
147
+ return (
148
+ <SheetOverlayFrame
149
+ closed={!context.open || context.hidden}
150
+ {...props}
151
+ onPress={mergeEvent(
152
+ props.onPress,
153
+ context.dismissOnOverlayPress
154
+ ? () => {
155
+ context.setOpen(false)
156
+ }
157
+ : undefined
158
+ )}
159
+ />
160
+ )
161
+ }
162
+ )
163
+
164
+ const selectionStyleSheet = isClient ? document.createElement('style') : null
165
+ if (selectionStyleSheet) {
166
+ document.head.appendChild(selectionStyleSheet)
167
+ }
168
+
169
+ export const SheetFrame = styled(YStack, {
170
+ name: 'SheetFrame',
81
171
  flex: 1,
82
172
  backgroundColor: '$background',
83
173
  borderTopLeftRadius: '$4',
84
174
  borderTopRightRadius: '$4',
85
175
  padding: '$4',
86
176
  width: '100%',
177
+ pointerEvents: 'auto',
87
178
  })
88
179
 
89
- export type DrawerProps = ScopedProps<
90
- {
91
- open?: boolean
92
- defaultOpen?: boolean
93
- defaultPosition?: number
94
- snapPoints?: number[]
95
- position?: number
96
- onChangePosition?: PositionChangeHandler
97
- children?: ReactNode
98
- },
99
- 'Drawer'
100
- >
101
-
102
- const useIsSSR = () => {
103
- const [val, setVal] = useState(isWeb ? isSSR : false)
104
- useEffect(() => {
105
- if (isWeb && !isSSR) {
106
- setVal(false)
107
- }
108
- }, [])
109
- return val
110
- }
180
+ // set all the way off screen
181
+ const HIDDEN_SIZE = 10_000
111
182
 
112
- export const Drawer = withStaticProperties(
183
+ export const Sheet = withStaticProperties(
113
184
  themeable(
114
- forwardRef<View, DrawerProps>((props, ref) => {
185
+ forwardRef<View, SheetProps>((props, ref) => {
115
186
  const {
116
- __scopeDrawer,
187
+ __scopeSheet,
117
188
  snapPoints: snapPointsProp = [80, 10],
189
+ open: openProp,
190
+ defaultOpen,
118
191
  children: childrenProp,
119
192
  position: positionProp,
120
193
  onChangePosition,
194
+ onChangeOpen,
121
195
  defaultPosition,
122
- ...rest
196
+ dismissOnOverlayPress = true,
197
+ animationConfig,
198
+ disableDrag: disableDragProp,
199
+ modal,
123
200
  } = props
124
- const isServerSide = useIsSSR()
125
201
 
126
- const [position, setPosition] = useControllableState({
202
+ // allows for sheets to be controlled by other components
203
+ const controller = useContext(SheetControllerContext)
204
+ const isHidden = controller?.hidden || false
205
+ const disableDrag = disableDragProp ?? controller?.disableDrag
206
+ const themeName = useThemeName()
207
+
208
+ const onChangeOpenInternal = (val: boolean) => {
209
+ controller?.onChangeOpen?.(val)
210
+ onChangeOpen?.(val)
211
+ }
212
+
213
+ const [open, setOpen] = useControllableState({
214
+ prop: controller?.open ?? openProp,
215
+ defaultProp: defaultOpen || true,
216
+ onChange: onChangeOpenInternal,
217
+ strategy: controller ? 'most-recent-wins' : 'prop-wins',
218
+ })
219
+
220
+ const [frameSize, setFrameSize] = useState<number>(0)
221
+ // eslint-disable-next-line react-hooks/exhaustive-deps
222
+ const snapPoints = useMemo(() => snapPointsProp, [JSON.stringify(snapPointsProp)])
223
+
224
+ // lets set -1 to be always the "open = false" position
225
+ const [position_, setPosition] = useControllableState({
127
226
  prop: positionProp,
128
- defaultProp: defaultPosition || 0,
227
+ defaultProp: defaultPosition || (open ? 0 : -1),
129
228
  onChange: onChangePosition,
130
- strategy: 'most-recent-wins',
131
229
  })
132
-
133
- const [layout, setLayout] = useState<LayoutRectangle>()
230
+ const position = open === false ? -1 : position_
134
231
 
135
232
  const positionValue = useRef<Animated.Value>()
136
233
  if (!positionValue.current) {
137
- positionValue.current = new Animated.Value(position)
234
+ positionValue.current = new Animated.Value(HIDDEN_SIZE)
138
235
  }
139
236
 
140
- // eslint-disable-next-line react-hooks/exhaustive-deps
141
- const snapPoints = useMemo(() => snapPointsProp, [JSON.stringify(snapPointsProp)])
142
-
143
- // we can put non-server side hooks after conditional because based on env
144
- if (isServerSide) {
145
- return null
146
- }
237
+ const [isResizing, setIsResizing] = useState(true)
238
+ useLayoutEffect(() => {
239
+ if (!isResizing) {
240
+ setIsResizing(true)
241
+ }
242
+ // eslint-disable-next-line react-hooks/exhaustive-deps
243
+ }, [modal])
147
244
 
148
- // eslint-disable-next-line react-hooks/rules-of-hooks
149
245
  const spring = useRef<Animated.CompositeAnimation | null>(null)
150
-
151
246
  function stopSpring() {
152
247
  spring.current?.stop()
153
248
  spring.current = null
154
249
  }
155
250
 
156
- // eslint-disable-next-line react-hooks/rules-of-hooks
251
+ // open must set position
252
+ const shouldSetPositionOpen = open && position < 0
253
+ useEffect(() => {
254
+ if (shouldSetPositionOpen) {
255
+ setPosition(0)
256
+ }
257
+ }, [setPosition, shouldSetPositionOpen])
258
+
157
259
  const positions = useMemo(
158
- () => snapPoints.map((point) => getYForPosition(point, layout)),
159
- [layout, snapPoints]
260
+ () => snapPoints.map((point) => getPercentSize(point, frameSize)),
261
+ [frameSize, snapPoints]
160
262
  )
161
263
 
162
- // eslint-disable-next-line react-hooks/rules-of-hooks
163
- const animateTo = useCallback(
164
- (position: number) => {
165
- if (!positionValue.current) return
166
- const toValue = positions[position]
167
- stopSpring()
168
- spring.current = Animated.spring(positionValue.current, {
264
+ const animateTo = useEvent((position: number) => {
265
+ const pos = positionValue.current
266
+ if (isHidden && open) return
267
+ if (!pos) return
268
+ if (frameSize === 0) return
269
+ const hiddenValue = frameSize === 0 ? HIDDEN_SIZE : frameSize
270
+ const toValue = isHidden || position === -1 ? hiddenValue : positions[position]
271
+ if (pos['_value'] === toValue) return
272
+ stopSpring()
273
+ if (isHidden || isResizing) {
274
+ if (isResizing) {
275
+ setIsResizing(false)
276
+ }
277
+ Animated.timing(pos, {
169
278
  useNativeDriver: !isWeb,
170
279
  toValue,
171
- })
172
- spring.current.start(({ finished }) => finished && stopSpring())
173
- },
174
- [positions]
175
- )
280
+ duration: 0,
281
+ }).start()
282
+ return
283
+ }
284
+ // dont bounce on initial measure to bottom
285
+ const overshootClamping = pos['_value'] === HIDDEN_SIZE
286
+ spring.current = Animated.spring(pos, {
287
+ useNativeDriver: !isWeb,
288
+ toValue,
289
+ overshootClamping,
290
+ ...animationConfig,
291
+ })
292
+ spring.current.start(({ finished }) => finished && stopSpring())
293
+ })
176
294
 
177
- // eslint-disable-next-line react-hooks/rules-of-hooks
178
295
  useLayoutEffect(() => {
179
296
  animateTo(position)
180
- }, [position, animateTo])
297
+ }, [isHidden, frameSize, position, animateTo])
181
298
 
182
- // eslint-disable-next-line react-hooks/rules-of-hooks
183
299
  const panResponder = useMemo(() => {
184
- if (!layout) return
185
- console.log('setup pan')
300
+ if (disableDrag) return
301
+ if (!frameSize) return
186
302
 
187
303
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
188
304
  const pos = positionValue.current!
189
305
  const minY = positions[0]
190
306
  let startY = pos['_value']
191
307
 
308
+ function makeUnselectable(val: boolean) {
309
+ if (!selectionStyleSheet) return
310
+ if (!val) {
311
+ selectionStyleSheet.innerText = ``
312
+ } else {
313
+ selectionStyleSheet.innerText = `:root * { user-select: none !important; -webkit-user-select: none !important; }`
314
+ }
315
+ }
316
+
317
+ const finish = (_e: GestureResponderEvent, { vy, dy }: PanResponderGestureState) => {
318
+ makeUnselectable(false)
319
+ const at = dy + startY
320
+ // seems liky vy goes up to about 4 at the very most (+ is down, - is up)
321
+ // lets base our multiplier on the total layout height
322
+ const end = at + frameSize * vy * 0.33
323
+ let closestPoint = 0
324
+ let dist = Infinity
325
+ for (let i = 0; i < positions.length; i++) {
326
+ const position = positions[i]
327
+ const curDist = end > position ? end - position : position - end
328
+ if (curDist < dist) {
329
+ dist = curDist
330
+ closestPoint = i
331
+ }
332
+ }
333
+ // have to call both because state may not change but need to snap back
334
+ setPosition(closestPoint)
335
+ animateTo(closestPoint)
336
+ }
337
+
192
338
  return PanResponder.create({
193
339
  onMoveShouldSetPanResponder: (_e, { dy }) => {
194
340
  // we could do some detection of other touchables and cancel here..
195
- // console.log('wut is', _e)
196
341
  return Math.abs(dy) > 6
197
342
  },
198
343
  onPanResponderGrant: () => {
344
+ makeUnselectable(true)
199
345
  stopSpring()
200
346
  startY = pos['_value']
201
347
  },
@@ -203,94 +349,110 @@ export const Drawer = withStaticProperties(
203
349
  const to = dy + startY
204
350
  pos.setValue(resisted(to, minY))
205
351
  },
206
- onPanResponderRelease: (_e, { vy, dy }) => {
207
- const at = dy + startY
208
- // seems liky vy goes up to about 4 at the very most (+ is down, - is up)
209
- // lets base our multiplier on the total layout height
210
- const end = at + layout.height * vy * 0.33
211
- let closestPoint = 0
212
- let dist = Infinity
213
- for (let i = 0; i < positions.length; i++) {
214
- const position = positions[i]
215
- const curDist = end > position ? end - position : position - end
216
- if (curDist < dist) {
217
- dist = curDist
218
- closestPoint = i
219
- }
220
- }
221
- // have to call both because state may not change but need to snap back
222
- setPosition(closestPoint)
223
- animateTo(closestPoint)
224
- },
352
+ onPanResponderEnd: finish,
353
+ onPanResponderTerminate: finish,
354
+ onPanResponderRelease: finish,
225
355
  })
226
- }, [layout, positions, setPosition])
356
+ }, [disableDrag, animateTo, frameSize, positions, setPosition])
227
357
 
228
358
  let handleComponent: React.ReactElement | null = null
229
- let backdropComponent: React.ReactElement | null = null
359
+ let overlayComponent: React.ReactElement | null = null
230
360
  let frameComponent: React.ReactElement | null = null
231
361
 
362
+ // TODO do more radix-like and don't require direct children descendents
232
363
  React.Children.forEach(childrenProp, (child) => {
233
364
  if (isValidElement(child)) {
234
- switch (child.type?.['staticConfig'].componentName) {
235
- case 'DrawerHandle':
365
+ const name = child.type?.['staticConfig']?.componentName
366
+ switch (name) {
367
+ case 'SheetHandle':
236
368
  handleComponent = child
237
369
  break
238
- case 'DrawerFrame':
370
+ case 'SheetFrame':
239
371
  frameComponent = child
240
372
  break
241
- case 'DrawerBackdrop':
242
- backdropComponent = child
373
+ case 'SheetOverlay':
374
+ overlayComponent = child
243
375
  break
244
376
  default:
245
- console.warn('Warning: passed invalid child to Drawer', child)
377
+ console.warn('Warning: passed invalid child to Sheet', child)
246
378
  }
247
379
  }
248
380
  })
249
381
 
250
- return (
251
- <DrawerProvider
252
- scope={__scopeDrawer}
382
+ const preventShown = controller?.hidden && controller?.open
383
+
384
+ if (preventShown) {
385
+ return null
386
+ }
387
+
388
+ const contents = (
389
+ <SheetProvider
390
+ dismissOnOverlayPress={dismissOnOverlayPress}
391
+ open={open}
392
+ hidden={isHidden}
393
+ scope={__scopeSheet}
253
394
  position={position}
254
395
  snapPoints={snapPoints}
255
396
  setPosition={setPosition}
397
+ setOpen={setOpen}
256
398
  >
257
- {backdropComponent}
258
- {handleComponent}
399
+ {isResizing ? null : overlayComponent}
400
+ {/* no fancy hidden animation etc for handle for now */}
401
+ {isHidden ? null : handleComponent}
259
402
  <Animated.View
403
+ ref={ref}
260
404
  {...panResponder?.panHandlers}
261
405
  onLayout={(e) => {
262
- setLayout(e.nativeEvent.layout)
406
+ const next = e.nativeEvent.layout.height
407
+ setFrameSize((prev) => {
408
+ const isBigChange = Math.abs(prev - next) > 50
409
+ setIsResizing(isBigChange)
410
+ return next
411
+ })
263
412
  }}
413
+ pointerEvents={open ? 'auto' : 'none'}
264
414
  style={{
415
+ position: 'absolute',
416
+ zIndex: 10,
265
417
  width: '100%',
266
418
  height: '100%',
267
- transform: [{ translateY: positionValue.current }],
419
+ transform: [{ translateY: frameSize === 0 ? HIDDEN_SIZE : positionValue.current }],
268
420
  }}
269
421
  >
270
- {frameComponent}
422
+ {isResizing ? null : frameComponent}
271
423
  </Animated.View>
272
- </DrawerProvider>
424
+ </SheetProvider>
273
425
  )
426
+
427
+ if (modal) {
428
+ return (
429
+ <Portal>
430
+ <Theme name={themeName}>{contents}</Theme>
431
+ </Portal>
432
+ )
433
+ }
434
+
435
+ return contents
274
436
  }),
275
437
  {
276
- componentName: 'Drawer',
438
+ componentName: 'Sheet',
277
439
  }
278
440
  ),
279
441
  {
280
- Handle: DrawerHandle,
281
- Frame: DrawerFrame,
282
- Backdrop: DrawerBackdrop,
442
+ Handle: SheetHandle,
443
+ Frame: SheetFrame,
444
+ Overlay: SheetOverlay,
283
445
  }
284
446
  )
285
447
 
286
- function getYForPosition(point?: number, layout?: LayoutRectangle) {
287
- if (!layout) return 0
448
+ function getPercentSize(point?: number, frameSize?: number) {
449
+ if (!frameSize) return 0
288
450
  if (point === undefined) {
289
451
  console.warn(`No snapPoint`)
290
452
  return 0
291
453
  }
292
454
  const pct = point / 100
293
- const next = layout.height - pct * layout.height
455
+ const next = frameSize - pct * frameSize
294
456
  return next
295
457
  }
296
458
 
@@ -305,4 +467,36 @@ function resisted(y: number, minY: number, maxOverflow = 25) {
305
467
  return y
306
468
  }
307
469
 
308
- export { createDrawerScope }
470
+ type SheetControllerContextValue = {
471
+ disableDrag?: boolean
472
+ open?: boolean
473
+ // hide without "closing" to prevent re-animation when shown again
474
+ hidden?: boolean
475
+ onChangeOpen?: React.Dispatch<React.SetStateAction<boolean>> | ((val: boolean) => void)
476
+ }
477
+
478
+ const SheetControllerContext = createContext<SheetControllerContextValue | null>(null)
479
+
480
+ export const SheetController = ({
481
+ children,
482
+ onChangeOpen: onChangeOpenProp,
483
+ ...value
484
+ }: Partial<SheetControllerContextValue> & { children?: React.ReactNode }) => {
485
+ const onChangeOpen = useEvent(onChangeOpenProp)
486
+
487
+ const memoValue = useMemo(
488
+ () => ({
489
+ open: value.open,
490
+ hidden: value.hidden,
491
+ disableDrag: value.disableDrag,
492
+ onChangeOpen,
493
+ }),
494
+ [onChangeOpen, value.open, value.hidden, value.disableDrag]
495
+ )
496
+
497
+ return (
498
+ <SheetControllerContext.Provider value={memoValue}>{children}</SheetControllerContext.Provider>
499
+ )
500
+ }
501
+
502
+ export { createSheetScope }