@sanity/ui 2.14.4 → 2.15.0

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,195 +1,109 @@
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'
1
+ import {AnimatePresence} from 'framer-motion'
2
+ import {startTransition, useMemo, useState} from 'react'
5
3
  import {useMounted} from '../../hooks/useMounted'
6
- import {usePrefersReducedMotion} from '../../hooks/usePrefersReducedMotion'
7
- import {Box} from '../../primitives'
8
- import {Layer} from '../../utils'
4
+ import {LayerProvider} from '../../utils'
9
5
  import {Toast} from './toast'
10
6
  import {ToastContext} from './toastContext'
7
+ import {ToastLayer, type ToastLayerProps} from './toastLayer'
11
8
  import {generateToastId} from './toastState'
12
9
  import {ToastContextValue, ToastParams} from './types'
13
10
 
14
11
  type ToastState = {
15
12
  dismiss: () => void
16
13
  id: string
14
+ updatedAt: number
17
15
  params: ToastParams
18
16
  }[]
19
17
 
20
18
  /**
21
19
  * @public
22
20
  */
23
- export interface ToastProviderProps {
21
+ export interface ToastProviderProps extends Omit<ToastLayerProps, 'children'> {
24
22
  children?: React.ReactNode
25
- padding?: number | number[]
26
- paddingX?: number | number[]
27
- paddingY?: number | number[]
28
23
  zOffset?: number | number[]
29
24
  }
30
25
 
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
-
49
26
  /**
50
27
  * @public
51
28
  */
52
29
  export function ToastProvider(props: ToastProviderProps): React.JSX.Element {
53
- const {children, padding = 4, paddingX, paddingY, zOffset} = props
54
- const [state, _setState] = useState<ToastState>([])
55
- const toastsRef = useRef<{[key: string]: {timeoutId: NodeJS.Timeout}}>({})
30
+ const {children, padding, paddingX, paddingY, gap, zOffset = 1} = props
31
+ const [state, setState] = useState<ToastState>([])
56
32
  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
- )
88
33
 
89
34
  const value: ToastContextValue = useMemo(() => {
90
35
  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
-
94
36
  const id = params.id || generateToastId()
95
37
  const duration = params.duration || 5000
96
38
 
97
- const dismiss = () => {
98
- const timeoutId = toastsRef.current[id]?.timeoutId
99
-
39
+ startTransition(() => {
100
40
  setState((prevState): ToastState => {
101
- const idx = prevState.findIndex((t) => t.id === id)
102
-
103
- if (idx > -1) {
104
- const toasts = prevState.slice(0)
105
-
106
- toasts.splice(idx, 1)
107
-
108
- return toasts
41
+ /**
42
+ * Backwards compatibility for `sanity` patterns workaround a lack of programatically dismissible toasts.
43
+ * It uses a super short duration that closes the toast immediately in previous versions of `@sanity/ui`.
44
+ * We interpret this as a request to dismiss the toast immediately, and remove it from the state right away.
45
+ * Even once we support programatic dismissal we'll need to keep this for backwards compatibility with v2 and v1.
46
+ */
47
+ if (duration === 0.01) {
48
+ return prevState.filter((toast) => toast.id !== id)
109
49
  }
110
50
 
111
- return prevState
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([
51
+ /**
52
+ * Creates a function to dismiss this specific toast.
53
+ * This function will be passed to the Toast component
54
+ * and called either on close button click or after duration.
55
+ */
56
+ const dismiss = () =>
57
+ startTransition(() =>
58
+ setState((currentState) => currentState.filter((toast) => toast.id !== id)),
59
+ )
60
+
61
+ /**
62
+ * Create updated state by:
63
+ * 1. Removing any existing toast with the same ID (prevents duplicates)
64
+ * 2. Adding the new toast with its dismiss handler
65
+ * 3. Updates the `updatedAt` timestamp, which resets progress bar count downs.
66
+ */
67
+ return [
68
+ ...prevState.filter((toast) => toast.id !== id),
124
69
  {
125
70
  dismiss,
126
71
  id,
72
+ updatedAt: Date.now(),
127
73
  params: {...params, duration},
128
74
  },
129
- ])
75
+ ]
76
+ })
130
77
  })
131
78
 
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
-
139
79
  return id
140
80
  }
141
81
 
142
82
  return {version: 0.0, push}
143
83
  }, [])
144
84
 
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
-
157
85
  return (
158
86
  <ToastContext.Provider value={value}>
159
87
  {children}
160
88
  {mounted && (
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>
89
+ <LayerProvider zOffset={zOffset}>
90
+ <ToastLayer padding={padding} paddingX={paddingX} paddingY={paddingY} gap={gap}>
91
+ <AnimatePresence initial={false} mode="popLayout">
92
+ {state.map(({dismiss, id, params, updatedAt}) => (
93
+ <Toast
94
+ key={id}
95
+ closable={params.closable}
96
+ description={params.description}
97
+ onClose={dismiss}
98
+ status={params.status}
99
+ title={params.title}
100
+ duration={params.duration}
101
+ updatedAt={updatedAt}
102
+ />
103
+ ))}
104
+ </AnimatePresence>
105
+ </ToastLayer>
106
+ </LayerProvider>
193
107
  )}
194
108
  </ToastContext.Provider>
195
109
  )
@@ -16,5 +16,11 @@ export interface ToastParams {
16
16
  */
17
17
  export interface ToastContextValue {
18
18
  version: 0.0
19
+ /**
20
+ * Creates or updates a toast notification.
21
+ * If a toast with the same ID already exists, it will be updated.
22
+ * If an ID is not provided, a random one will be generated.
23
+ * The returned ID can be used to programatically update a toast.
24
+ */
19
25
  push: (params: ToastParams) => string
20
26
  }
@@ -10,47 +10,60 @@ export const EMPTY_ARRAY: never[] = []
10
10
  */
11
11
  export const EMPTY_RECORD: Record<string, never> = {}
12
12
 
13
- /**
14
- * @internal
15
- */
16
- export const POPOVER_MOTION_CONTENT_OPACITY_PROPERTY = '--motion-content-opacity' as string
13
+ const POPOVER_MOTION_DURATION = 0.2
17
14
 
18
15
  /**
19
16
  * Shared `framer-motion` variants used by `Popover` and `Tooltip` components.
20
17
  * @internal
21
18
  */
22
19
  export const POPOVER_MOTION_PROPS: {
23
- animate: Variant
24
- initial: Variant
25
- exit: Variant
20
+ card: {
21
+ initial: Variant
22
+ hidden: Variant
23
+ visible: Variant
24
+ scaleIn: Variant
25
+ scaleOut: Variant
26
+ }
27
+ children: {
28
+ hidden: Variant
29
+ visible: Variant
30
+ }
26
31
  transition: Transition
27
32
  } = {
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,
33
+ card: {
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
+ },
45
54
  },
46
- exit: {
47
- opacity: 0,
48
- [POPOVER_MOTION_CONTENT_OPACITY_PROPERTY as string]: -1,
49
- scale: 0.97,
55
+ children: {
56
+ hidden: {
57
+ opacity: 0,
58
+ },
59
+ visible: {
60
+ opacity: 1,
61
+ },
50
62
  },
51
63
  transition: {
52
- duration: 0.4,
53
64
  type: 'spring',
65
+ visualDuration: POPOVER_MOTION_DURATION,
66
+ bounce: 0.25,
54
67
  },
55
68
  }
56
69
 
@@ -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_CONTENT_OPACITY_PROPERTY, POPOVER_MOTION_PROPS} from '../../constants'
6
+ import {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,10 +22,11 @@ const MotionCard = styled(motion.create(Card))`
22
22
  flex-direction: column;
23
23
  width: max-content;
24
24
  min-width: min-content;
25
- & > * {
26
- opacity: var(${POPOVER_MOTION_CONTENT_OPACITY_PROPERTY}, 1);
27
- will-change: opacity;
28
- }
25
+ will-change: transform;
26
+ `
27
+
28
+ const MotionFlex = styled(motion.create(Flex))`
29
+ will-change: opacity;
29
30
  `
30
31
 
31
32
  /**
@@ -131,13 +132,24 @@ export const PopoverCard = memo(
131
132
  sizing="border"
132
133
  style={rootStyle}
133
134
  tone={tone}
134
- {...(animate ? POPOVER_MOTION_PROPS : {})}
135
+ variants={POPOVER_MOTION_PROPS.card}
136
+ transition={POPOVER_MOTION_PROPS.transition}
137
+ initial={animate ? ['hidden', 'initial'] : undefined}
138
+ animate={animate ? ['visible', 'scaleIn'] : undefined}
139
+ exit={animate ? ['hidden', 'scaleOut'] : undefined}
135
140
  >
136
- <Flex data-ui="Popover__wrapper" direction="column" flex={1} overflow={overflow}>
141
+ <MotionFlex
142
+ data-ui="Popover__wrapper"
143
+ direction="column"
144
+ flex={1}
145
+ overflow={overflow}
146
+ variants={POPOVER_MOTION_PROPS.children}
147
+ transition={POPOVER_MOTION_PROPS.transition}
148
+ >
137
149
  <Flex direction="column" flex={1} padding={padding}>
138
150
  {children}
139
151
  </Flex>
140
- </Flex>
152
+ </MotionFlex>
141
153
 
142
154
  {arrow && (
143
155
  <Arrow
@@ -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_CONTENT_OPACITY_PROPERTY, POPOVER_MOTION_PROPS} from '../../constants'
5
+ import {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,10 +13,7 @@ import {
13
13
  } from './constants'
14
14
 
15
15
  const MotionCard = styled(motion.create(Card))`
16
- & > * {
17
- opacity: var(${POPOVER_MOTION_CONTENT_OPACITY_PROPERTY}, 1);
18
- will-change: opacity;
19
- }
16
+ will-change: transform;
20
17
  `
21
18
 
22
19
  /**
@@ -89,7 +86,11 @@ export const TooltipCard = memo(
89
86
  scheme={scheme}
90
87
  shadow={shadow}
91
88
  style={rootStyle}
92
- {...(animate ? POPOVER_MOTION_PROPS : {})}
89
+ variants={POPOVER_MOTION_PROPS.card}
90
+ transition={POPOVER_MOTION_PROPS.transition}
91
+ initial={animate ? ['hidden', 'initial'] : undefined}
92
+ animate={animate ? ['visible', 'scaleIn'] : undefined}
93
+ exit={animate ? ['hidden', 'scaleOut'] : undefined}
93
94
  >
94
95
  {children}
95
96