@sanity/ui 2.14.4-canary.3 → 2.14.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.
@@ -1,107 +1,195 @@
1
- import {AnimatePresence} from 'framer-motion'
2
- import {startTransition, useMemo, useState} from 'react'
1
+ import {AnimatePresence, motion, type Variants} from 'framer-motion'
2
+ import {useMemo, useRef, useState, startTransition, useEffect} from 'react'
3
+ import {styled} from 'styled-components'
4
+ import {POPOVER_MOTION_CONTENT_OPACITY_PROPERTY} from '../../constants'
3
5
  import {useMounted} from '../../hooks/useMounted'
4
- import {LayerProvider} from '../../utils'
6
+ import {usePrefersReducedMotion} from '../../hooks/usePrefersReducedMotion'
7
+ import {Box} from '../../primitives'
8
+ import {Layer} from '../../utils'
5
9
  import {Toast} from './toast'
6
10
  import {ToastContext} from './toastContext'
7
- import {ToastLayer, type ToastLayerProps} from './toastLayer'
8
11
  import {generateToastId} from './toastState'
9
12
  import {ToastContextValue, ToastParams} from './types'
10
13
 
11
14
  type ToastState = {
12
15
  dismiss: () => void
13
16
  id: string
14
- updatedAt: number
15
17
  params: ToastParams
16
18
  }[]
17
19
 
18
20
  /**
19
21
  * @public
20
22
  */
21
- export interface ToastProviderProps extends Omit<ToastLayerProps, 'children'> {
23
+ export interface ToastProviderProps {
22
24
  children?: React.ReactNode
25
+ padding?: number | number[]
26
+ paddingX?: number | number[]
27
+ paddingY?: number | number[]
23
28
  zOffset?: number | number[]
24
29
  }
25
30
 
31
+ const StyledToastProvider = styled(Layer)`
32
+ position: fixed;
33
+ top: 0;
34
+ left: 0;
35
+ right: 0;
36
+ bottom: 0;
37
+ pointer-events: none;
38
+ `
39
+
40
+ const ToastContainer = styled.div`
41
+ box-sizing: border-box;
42
+ position: absolute;
43
+ right: 0;
44
+ bottom: 0;
45
+ max-width: 420px;
46
+ width: 100%;
47
+ `
48
+
26
49
  /**
27
50
  * @public
28
51
  */
29
52
  export function ToastProvider(props: ToastProviderProps): React.JSX.Element {
30
- const {children, padding, paddingX, paddingY, gap, zOffset = 1} = props
31
- const [state, setState] = useState<ToastState>([])
53
+ const {children, padding = 4, paddingX, paddingY, zOffset} = props
54
+ const [state, _setState] = useState<ToastState>([])
55
+ const toastsRef = useRef<{[key: string]: {timeoutId: NodeJS.Timeout}}>({})
32
56
  const mounted = useMounted()
57
+ const prefersReducedMotion = usePrefersReducedMotion()
58
+ const variants = useMemo<Variants>(
59
+ () => ({
60
+ /**
61
+ * These variants makes use of special timing, by using a negative opacity as a starting position,
62
+ * as well as double opacity as the end position.
63
+ * The purpose of this is to make the tooltip/popover container appear before the content, and when exiting
64
+ * we want the content to disappear faster than the container.
65
+ */
66
+ initial: {
67
+ opacity: 0,
68
+ [POPOVER_MOTION_CONTENT_OPACITY_PROPERTY]: -1,
69
+ y: 32,
70
+ scale: 0.25,
71
+ willChange: 'transform',
72
+ },
73
+ animate: {
74
+ opacity: 2,
75
+ [POPOVER_MOTION_CONTENT_OPACITY_PROPERTY]: 1,
76
+ y: 0,
77
+ scale: 1,
78
+ },
79
+ exit: {
80
+ opacity: 0,
81
+ [POPOVER_MOTION_CONTENT_OPACITY_PROPERTY]: -1,
82
+ scale: 0.5,
83
+ },
84
+ transition: {duration: prefersReducedMotion ? 0 : 0.2},
85
+ }),
86
+ [prefersReducedMotion],
87
+ )
33
88
 
34
89
  const value: ToastContextValue = useMemo(() => {
35
90
  const push = (params: ToastParams) => {
91
+ // Wrap setState in startTransition to allow React to give input state updates higher priority
92
+ const setState: typeof _setState = (state) => startTransition(() => _setState(state))
93
+
36
94
  const id = params.id || generateToastId()
37
95
  const duration = params.duration || 5000
38
96
 
39
- startTransition(() => {
40
- setState((prevState): ToastState => {
41
- const dismiss = () => {
42
- startTransition(() => {
43
- setState((prevState): ToastState => {
44
- const idx = prevState.findIndex((t) => t.id === id)
45
-
46
- if (idx > -1) {
47
- const toasts = prevState.slice(0)
97
+ const dismiss = () => {
98
+ const timeoutId = toastsRef.current[id]?.timeoutId
48
99
 
49
- toasts.splice(idx, 1)
100
+ setState((prevState): ToastState => {
101
+ const idx = prevState.findIndex((t) => t.id === id)
50
102
 
51
- return toasts
52
- }
103
+ if (idx > -1) {
104
+ const toasts = prevState.slice(0)
53
105
 
54
- return prevState
55
- })
56
- })
57
- }
106
+ toasts.splice(idx, 1)
58
107
 
59
- // BC legacy support
60
- if (duration === 0.01) {
61
- return prevState.filter((t) => t.id !== id)
108
+ return toasts
62
109
  }
63
110
 
64
111
  return prevState
65
- .filter((t) => t.id !== id)
66
- .concat([
67
- {
68
- dismiss,
69
- id,
70
- updatedAt: Date.now(),
71
- params: {...params, duration},
72
- },
73
- ])
74
112
  })
113
+
114
+ if (timeoutId !== undefined) {
115
+ clearTimeout(timeoutId)
116
+ delete toastsRef.current[id]
117
+ }
118
+ }
119
+
120
+ setState((prevState): ToastState => {
121
+ return prevState
122
+ .filter((t) => t.id !== id)
123
+ .concat([
124
+ {
125
+ dismiss,
126
+ id,
127
+ params: {...params, duration},
128
+ },
129
+ ])
75
130
  })
76
131
 
132
+ if (toastsRef.current[id]) {
133
+ clearTimeout(toastsRef.current[id].timeoutId)
134
+ delete toastsRef.current[id]
135
+ }
136
+
137
+ toastsRef.current[id] = {timeoutId: setTimeout(dismiss, duration)}
138
+
77
139
  return id
78
140
  }
79
141
 
80
142
  return {version: 0.0, push}
81
143
  }, [])
82
144
 
145
+ // clear timeouts on unmount
146
+ useEffect(
147
+ () => () => {
148
+ for (const {timeoutId} of Object.values(toastsRef.current)) {
149
+ clearTimeout(timeoutId)
150
+ }
151
+
152
+ toastsRef.current = {}
153
+ },
154
+ [],
155
+ )
156
+
83
157
  return (
84
158
  <ToastContext.Provider value={value}>
85
159
  {children}
86
160
  {mounted && (
87
- <LayerProvider zOffset={zOffset}>
88
- <ToastLayer padding={padding} paddingX={paddingX} paddingY={paddingY} gap={gap}>
89
- <AnimatePresence initial={false} mode="popLayout">
90
- {state.map(({dismiss, id, params, updatedAt}) => (
91
- <Toast
92
- key={id}
93
- closable={params.closable}
94
- description={params.description}
95
- onClose={dismiss}
96
- status={params.status}
97
- title={params.title}
98
- duration={params.duration}
99
- updatedAt={updatedAt}
100
- />
101
- ))}
102
- </AnimatePresence>
103
- </ToastLayer>
104
- </LayerProvider>
161
+ <StyledToastProvider data-ui="ToastProvider" zOffset={zOffset}>
162
+ <ToastContainer>
163
+ <Box padding={padding} paddingX={paddingX} paddingY={paddingY}>
164
+ <AnimatePresence initial={false}>
165
+ {state.map(({dismiss, id, params}) => (
166
+ <motion.div
167
+ key={id}
168
+ layout="position"
169
+ initial="initial"
170
+ animate="animate"
171
+ exit="exit"
172
+ variants={variants}
173
+ transition={
174
+ prefersReducedMotion
175
+ ? {duration: 0}
176
+ : {type: 'spring', damping: 30, stiffness: 400}
177
+ }
178
+ >
179
+ <Toast
180
+ closable={params.closable}
181
+ description={params.description}
182
+ onClose={dismiss}
183
+ status={params.status}
184
+ title={params.title}
185
+ duration={params.duration}
186
+ />
187
+ </motion.div>
188
+ ))}
189
+ </AnimatePresence>
190
+ </Box>
191
+ </ToastContainer>
192
+ </StyledToastProvider>
105
193
  )}
106
194
  </ToastContext.Provider>
107
195
  )
@@ -10,60 +10,47 @@ export const EMPTY_ARRAY: never[] = []
10
10
  */
11
11
  export const EMPTY_RECORD: Record<string, never> = {}
12
12
 
13
- const POPOVER_MOTION_DURATION = 0.2
13
+ /**
14
+ * @internal
15
+ */
16
+ export const POPOVER_MOTION_CONTENT_OPACITY_PROPERTY = '--motion-content-opacity' as string
14
17
 
15
18
  /**
16
19
  * Shared `framer-motion` variants used by `Popover` and `Tooltip` components.
17
20
  * @internal
18
21
  */
19
22
  export const POPOVER_MOTION_PROPS: {
20
- outerVariants: {
21
- initial: Variant
22
- hidden: Variant
23
- visible: Variant
24
- scaleIn: Variant
25
- scaleOut: Variant
26
- }
27
- innerVariants: {
28
- hidden: Variant
29
- visible: Variant
30
- }
23
+ animate: Variant
24
+ initial: Variant
25
+ exit: Variant
31
26
  transition: Transition
32
27
  } = {
33
- outerVariants: {
34
- initial: {
35
- scale: 0.97,
36
- willChange: 'transform',
37
- },
38
- hidden: {
39
- opacity: 0,
40
- },
41
- visible: {
42
- opacity: 1,
43
- transition: {
44
- when: 'beforeChildren',
45
- duration: POPOVER_MOTION_DURATION / 2,
46
- },
47
- },
48
- scaleIn: {
49
- scale: 1,
50
- },
51
- scaleOut: {
52
- scale: 0.97,
53
- },
28
+ /**
29
+ * These variants makes use of special timing, by using a negative opacity as a starting position,
30
+ * as well as double opacity as the end position.
31
+ * The purpose of this is to make the tooltip/popover container appear before the content, and when exiting
32
+ * we want the content to disappear faster than the container.
33
+ */
34
+ initial: {
35
+ opacity: 0.5,
36
+ // the nagative opacity here, as well as the double opacity further down, are to make the content appear after the backgdrop, and when exiting the content should disappear first.
37
+ [POPOVER_MOTION_CONTENT_OPACITY_PROPERTY as string]: -1,
38
+ scale: 0.97,
39
+ willChange: 'transform',
40
+ },
41
+ animate: {
42
+ opacity: 2,
43
+ [POPOVER_MOTION_CONTENT_OPACITY_PROPERTY as string]: 1,
44
+ scale: 1,
54
45
  },
55
- innerVariants: {
56
- hidden: {
57
- opacity: 0,
58
- },
59
- visible: {
60
- opacity: 1,
61
- },
46
+ exit: {
47
+ opacity: 0,
48
+ [POPOVER_MOTION_CONTENT_OPACITY_PROPERTY as string]: -1,
49
+ scale: 0.97,
62
50
  },
63
51
  transition: {
52
+ duration: 0.4,
64
53
  type: 'spring',
65
- visualDuration: POPOVER_MOTION_DURATION,
66
- bounce: 0.25,
67
54
  },
68
55
  }
69
56
 
@@ -10,7 +10,6 @@ import {ResponsiveGridProps} from '../types'
10
10
  */
11
11
  export interface GridProps extends Omit<BoxProps, 'display'>, ResponsiveGridProps {}
12
12
 
13
- // @TODO this might be how ToastLayer has to be setup
14
13
  const StyledGrid = styled(Box)<ResponsiveGridStyleProps>(responsiveGridStyle)
15
14
 
16
15
  /**
@@ -3,7 +3,7 @@ import {ThemeColorSchemeKey} from '@sanity/ui/theme'
3
3
  import {type MotionProps, motion} from 'framer-motion'
4
4
  import React, {CSSProperties, forwardRef, memo, useMemo} from 'react'
5
5
  import {styled} from 'styled-components'
6
- import {POPOVER_MOTION_PROPS} from '../../constants'
6
+ import {POPOVER_MOTION_CONTENT_OPACITY_PROPERTY, POPOVER_MOTION_PROPS} from '../../constants'
7
7
  import {BoxOverflow, CardTone, Placement, PopoverMargins, Radius} from '../../types'
8
8
  import {Arrow, useLayer} from '../../utils'
9
9
  import {Card, CardProps} from '../card'
@@ -22,11 +22,10 @@ const MotionCard = styled(motion.create(Card))`
22
22
  flex-direction: column;
23
23
  width: max-content;
24
24
  min-width: min-content;
25
- will-change: transform;
26
- `
27
-
28
- const MotionFlex = styled(motion.create(Flex))`
29
- will-change: opacity;
25
+ & > * {
26
+ opacity: var(${POPOVER_MOTION_CONTENT_OPACITY_PROPERTY}, 1);
27
+ will-change: opacity;
28
+ }
30
29
  `
31
30
 
32
31
  /**
@@ -132,24 +131,13 @@ export const PopoverCard = memo(
132
131
  sizing="border"
133
132
  style={rootStyle}
134
133
  tone={tone}
135
- variants={POPOVER_MOTION_PROPS.outerVariants}
136
- transition={POPOVER_MOTION_PROPS.transition}
137
- initial={animate ? ['hidden', 'initial'] : undefined}
138
- animate={animate ? ['visible', 'scaleIn'] : undefined}
139
- exit={animate ? ['hidden', 'scaleOut'] : undefined}
134
+ {...(animate ? POPOVER_MOTION_PROPS : {})}
140
135
  >
141
- <MotionFlex
142
- data-ui="Popover__wrapper"
143
- direction="column"
144
- flex={1}
145
- overflow={overflow}
146
- variants={POPOVER_MOTION_PROPS.innerVariants}
147
- transition={POPOVER_MOTION_PROPS.transition}
148
- >
136
+ <Flex data-ui="Popover__wrapper" direction="column" flex={1} overflow={overflow}>
149
137
  <Flex direction="column" flex={1} padding={padding}>
150
138
  {children}
151
139
  </Flex>
152
- </MotionFlex>
140
+ </Flex>
153
141
 
154
142
  {arrow && (
155
143
  <Arrow
@@ -219,6 +219,49 @@ export const Tooltip = forwardRef(function Tooltip(
219
219
  [isInsideGroup, delayGroupContext, openDelay, tooltipId, closeDelay, setIsOpen],
220
220
  )
221
221
 
222
+ const handleBlur = useCallback(
223
+ (e: FocusEvent) => {
224
+ handleIsOpenChange(false)
225
+ childProp?.props?.onBlur?.(e)
226
+ },
227
+ [childProp?.props, handleIsOpenChange],
228
+ )
229
+ const handleClick = useCallback(
230
+ (e: MouseEvent) => {
231
+ handleIsOpenChange(false, true)
232
+ childProp?.props.onClick?.(e)
233
+ },
234
+ [childProp?.props, handleIsOpenChange],
235
+ )
236
+ const handleContextMenu = useCallback(
237
+ (e: MouseEvent) => {
238
+ handleIsOpenChange(false, true)
239
+ childProp?.props.onContextMenu?.(e)
240
+ },
241
+ [childProp?.props, handleIsOpenChange],
242
+ )
243
+ const handleFocus = useCallback(
244
+ (e: FocusEvent) => {
245
+ handleIsOpenChange(true)
246
+ childProp?.props?.onFocus?.(e)
247
+ },
248
+ [childProp?.props, handleIsOpenChange],
249
+ )
250
+ const handleMouseEnter = useCallback(
251
+ (e: MouseEvent) => {
252
+ handleIsOpenChange(true)
253
+ childProp?.props?.onMouseEnter?.(e)
254
+ },
255
+ [childProp?.props, handleIsOpenChange],
256
+ )
257
+ const handleMouseLeave = useCallback(
258
+ (e: MouseEvent) => {
259
+ handleIsOpenChange(false)
260
+ childProp?.props?.onMouseLeave?.(e)
261
+ },
262
+ [childProp?.props, handleIsOpenChange],
263
+ )
264
+
222
265
  // Handle closing the tooltip when the mouse leaves the referenceElement
223
266
  useCloseOnMouseLeave({handleIsOpenChange, referenceElement, showTooltip})
224
267
 
@@ -249,28 +292,6 @@ export const Tooltip = forwardRef(function Tooltip(
249
292
  }
250
293
  }, [handleIsOpenChange, showTooltip])
251
294
 
252
- /* eslint-disable padding-line-between-statements */
253
- // Handle delays and grouping
254
- const handleChildEvent = useEffectEvent((open: boolean, immediate?: boolean) =>
255
- handleIsOpenChange(open, immediate),
256
- )
257
- useEffect(() => {
258
- if (!referenceElement) return
259
-
260
- const controller = new AbortController()
261
- const {signal} = controller
262
-
263
- referenceElement.addEventListener('blur', () => handleChildEvent(false), {signal})
264
- referenceElement.addEventListener('focus', () => handleChildEvent(true), {signal})
265
- referenceElement.addEventListener('mouseenter', () => handleChildEvent(true), {signal})
266
- referenceElement.addEventListener('mouseleave', () => handleChildEvent(false), {signal})
267
- referenceElement.addEventListener('click', () => handleChildEvent(false, true), {signal})
268
- referenceElement.addEventListener('contextmenu', () => handleChildEvent(false, true), {signal})
269
-
270
- return () => controller.abort()
271
- }, [referenceElement])
272
- /* eslint-enable padding-line-between-statements */
273
-
274
295
  // // Set the max width of the tooltip based on boundaries and portals
275
296
  useLayoutEffect(() => {
276
297
  // Get the maximum tooltip width (sans tooltip padding)
@@ -303,8 +324,24 @@ export const Tooltip = forwardRef(function Tooltip(
303
324
  const child = useMemo(() => {
304
325
  if (!childProp) return null
305
326
 
306
- return cloneElement(childProp, {ref: setReferenceElement})
307
- }, [childProp])
327
+ return cloneElement(childProp, {
328
+ onBlur: handleBlur,
329
+ onFocus: handleFocus,
330
+ onMouseEnter: handleMouseEnter,
331
+ onMouseLeave: handleMouseLeave,
332
+ onClick: handleClick,
333
+ onContextMenu: handleContextMenu,
334
+ ref: setReferenceElement,
335
+ })
336
+ }, [
337
+ childProp,
338
+ handleBlur,
339
+ handleClick,
340
+ handleContextMenu,
341
+ handleFocus,
342
+ handleMouseEnter,
343
+ handleMouseLeave,
344
+ ])
308
345
 
309
346
  // If there's a child then we need to set the reference element to the cloned child ref
310
347
  // and if child changes we make sure to update or remove the reference element.
@@ -2,7 +2,7 @@ import {ThemeColorSchemeKey} from '@sanity/ui/theme'
2
2
  import {type MotionProps, motion} from 'framer-motion'
3
3
  import React, {CSSProperties, forwardRef, memo, useMemo} from 'react'
4
4
  import {styled} from 'styled-components'
5
- import {POPOVER_MOTION_PROPS} from '../../constants'
5
+ import {POPOVER_MOTION_CONTENT_OPACITY_PROPERTY, POPOVER_MOTION_PROPS} from '../../constants'
6
6
  import {Placement, Radius} from '../../types'
7
7
  import {Arrow} from '../../utils'
8
8
  import {Card, CardProps} from '../card'
@@ -13,7 +13,10 @@ import {
13
13
  } from './constants'
14
14
 
15
15
  const MotionCard = styled(motion.create(Card))`
16
- will-change: transform;
16
+ & > * {
17
+ opacity: var(${POPOVER_MOTION_CONTENT_OPACITY_PROPERTY}, 1);
18
+ will-change: opacity;
19
+ }
17
20
  `
18
21
 
19
22
  /**
@@ -86,11 +89,7 @@ export const TooltipCard = memo(
86
89
  scheme={scheme}
87
90
  shadow={shadow}
88
91
  style={rootStyle}
89
- variants={POPOVER_MOTION_PROPS.outerVariants}
90
- transition={POPOVER_MOTION_PROPS.transition}
91
- initial={animate ? ['hidden', 'initial'] : undefined}
92
- animate={animate ? ['visible', 'scaleIn'] : undefined}
93
- exit={animate ? ['hidden', 'scaleOut'] : undefined}
92
+ {...(animate ? POPOVER_MOTION_PROPS : {})}
94
93
  >
95
94
  {children}
96
95
 
@@ -66,8 +66,8 @@ const LayerChildren = forwardRef(function LayerChildren(
66
66
 
67
67
  return (
68
68
  <StyledLayer
69
- data-ui="Layer"
70
69
  {...restProps}
70
+ data-ui="Layer"
71
71
  onFocus={handleFocus}
72
72
  ref={ref}
73
73
  style={{...style, zIndex}}
@@ -1,50 +0,0 @@
1
- import {styled} from 'styled-components'
2
- import {Grid} from '../../primitives/grid'
3
- import {useLayer} from '../../utils'
4
-
5
- /**
6
- * @public
7
- */
8
- export interface ToastLayerProps {
9
- children: React.ReactNode
10
- padding?: number | number[]
11
- paddingX?: number | number[]
12
- paddingY?: number | number[]
13
- gap?: number | number[]
14
- }
15
-
16
- /**
17
- * @internal
18
- */
19
- export function ToastLayer(props: ToastLayerProps): React.JSX.Element {
20
- const {children, padding = 4, paddingX, paddingY, gap = 3} = props
21
- const {zIndex} = useLayer()
22
-
23
- return (
24
- <StyledLayer
25
- forwardedAs="ul"
26
- data-ui="ToastProvider"
27
- padding={padding}
28
- paddingX={paddingX}
29
- paddingY={paddingY}
30
- gap={gap}
31
- columns={1}
32
- style={{zIndex}}
33
- >
34
- {children}
35
- </StyledLayer>
36
- )
37
- }
38
-
39
- ToastLayer.displayName = 'ToastLayer'
40
-
41
- const StyledLayer = styled(Grid)`
42
- box-sizing: border-box;
43
- position: fixed;
44
- right: 0;
45
- bottom: 0;
46
- list-style: none;
47
- pointer-events: none;
48
- max-width: 420px;
49
- width: 100%;
50
- `