@sanity/ui 1.7.12 → 1.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/ui",
3
- "version": "1.7.12",
3
+ "version": "1.8.0",
4
4
  "sideEffects": false,
5
5
  "types": "./dist/index.d.ts",
6
6
  "source": "./src/index.ts",
@@ -0,0 +1,66 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import {renderHook, act} from '@testing-library/react'
4
+ import {useDelayedState} from './useDelayedState'
5
+
6
+ describe('useDelayedState', () => {
7
+ it('should update state immediately if delay is not provided', () => {
8
+ const {result} = renderHook(() => useDelayedState(false))
9
+ const [, setState] = result.current
10
+ act(() => {
11
+ setState(true)
12
+ })
13
+ expect(result.current[0]).toBe(true)
14
+ })
15
+
16
+ it('should update state after delay if delay is provided', async () => {
17
+ jest.useFakeTimers()
18
+ const {result} = renderHook(() => useDelayedState(false))
19
+ const [, setState] = result.current
20
+
21
+ act(() => {
22
+ setState(true, 1000)
23
+ })
24
+ expect(result.current[0]).toBe(false)
25
+ act(() => {
26
+ jest.advanceTimersByTime(500)
27
+ })
28
+ expect(result.current[0]).toBe(false)
29
+
30
+ act(() => {
31
+ jest.advanceTimersByTime(500)
32
+ })
33
+
34
+ expect(result.current[0]).toBe(true)
35
+ })
36
+
37
+ it('should update state with callback function', () => {
38
+ const {result} = renderHook(() => useDelayedState(false))
39
+ const [, setState] = result.current
40
+
41
+ act(() => {
42
+ setState((prev: boolean) => !prev)
43
+ })
44
+
45
+ expect(result.current[0]).toBe(true)
46
+ })
47
+
48
+ it('should cancel update if the set state was called with a new state', () => {
49
+ const {result} = renderHook(() => useDelayedState(false))
50
+ const [, setState] = result.current
51
+
52
+ act(() => {
53
+ setState(true, 1000)
54
+ })
55
+ expect(result.current[0]).toBe(false)
56
+
57
+ jest.advanceTimersByTime(500)
58
+
59
+ act(() => {
60
+ setState(false)
61
+ })
62
+ jest.advanceTimersByTime(600)
63
+ // Even after 1.1 seconds, the state should continue being false, because it was cancelled by a next setState call
64
+ expect(result.current[0]).toBe(false)
65
+ })
66
+ })
@@ -0,0 +1,29 @@
1
+ import {useRef} from 'react'
2
+ import {useState, useCallback, SetStateAction} from 'react'
3
+
4
+ /**
5
+ * @beta
6
+ */
7
+ export function useDelayedState<S>(
8
+ initialState: S | (() => S),
9
+ ): [S, (nextState: SetStateAction<S>, delay?: number) => void] {
10
+ const [state, setState] = useState(initialState)
11
+ const delayedAction = useRef<NodeJS.Timeout | undefined>()
12
+
13
+ const onStateChange = useCallback((nextState: SetStateAction<S>, delay?: number) => {
14
+ const action = () => {
15
+ setState(nextState)
16
+ }
17
+
18
+ // A new state change has been initiated, cancel the previous one.
19
+ if (delayedAction.current) {
20
+ clearTimeout(delayedAction.current)
21
+ delayedAction.current = undefined
22
+ }
23
+
24
+ if (!delay) return action()
25
+ delayedAction.current = setTimeout(action, delay)
26
+ }, [])
27
+
28
+ return [state, onStateChange]
29
+ }
@@ -1,5 +1,5 @@
1
- import {Button, Card, Flex, Text, Tooltip} from '@sanity/ui'
2
- import {useBoolean, useSelect, useText} from '@sanity/ui-workshop'
1
+ import {Button, Card, Flex, Text, Tooltip, TooltipDelayGroupProvider, Stack} from '@sanity/ui'
2
+ import {useBoolean, useSelect, useText, useNumber} from '@sanity/ui-workshop'
3
3
  import {
4
4
  WORKSHOP_PLACEMENT_OPTIONS,
5
5
  WORKSHOP_SHADOW_OPTIONS,
@@ -11,20 +11,95 @@ export default function PropsStory() {
11
11
  const padding = useSelect('Padding', WORKSHOP_SPACE_OPTIONS, 2, 'Props')
12
12
  const placement = useSelect('Placement', WORKSHOP_PLACEMENT_OPTIONS, 'top')
13
13
  const portal = useBoolean('Portal', true)
14
+ const openDelay = useNumber('Open Delay', 200) || 0
15
+ const closeDelay = useNumber('Close Delay', 200) || 0
14
16
  const shadow = useSelect('Shadow', WORKSHOP_SHADOW_OPTIONS, 2)
15
17
 
16
18
  return (
17
19
  <Card height="fill">
18
- <Flex align="center" height="fill" justify="center" padding={4} sizing="border">
19
- <Tooltip
20
- content={<Text size={1}>{content}</Text>}
21
- padding={padding}
22
- placement={placement}
23
- portal={portal}
24
- shadow={shadow}
20
+ <Flex
21
+ height="fill"
22
+ align="center"
23
+ justify="center"
24
+ padding={8}
25
+ sizing="border"
26
+ direction="column"
27
+ gap={4}
28
+ >
29
+ <Stack
30
+ padding={4}
31
+ style={{outline: '1px solid var(--card-border-color)', width: '100%', maxWidth: '640px'}}
32
+ space={4}
25
33
  >
26
- <Button mode="bleed" text="Hover me" />
27
- </Tooltip>
34
+ <Text align="center" size={3}>
35
+ Standalone tooltip
36
+ </Text>
37
+ <Flex align="center" justify="center" padding={4} sizing="border">
38
+ <Tooltip
39
+ content={<Text size={1}>{content}</Text>}
40
+ padding={padding}
41
+ placement={placement}
42
+ portal={portal}
43
+ shadow={shadow}
44
+ delay={{
45
+ open: openDelay,
46
+ close: closeDelay,
47
+ }}
48
+ >
49
+ <Button mode="bleed" text="Hover me" />
50
+ </Tooltip>
51
+ </Flex>
52
+ </Stack>
53
+ <Stack
54
+ padding={4}
55
+ style={{outline: '1px solid var(--card-border-color)', width: '100%', maxWidth: '640px'}}
56
+ space={4}
57
+ >
58
+ <Text align="center" size={3}>
59
+ Grouped tooltips
60
+ </Text>
61
+ <Text align="center" size={1}>
62
+ All tooltip delays are set to 1ms after the first tooltip within a DelayGroupProvider
63
+ opens.
64
+ </Text>
65
+ <Flex align="center" justify="center" padding={4} sizing="border" gap={4}>
66
+ <TooltipDelayGroupProvider
67
+ delay={{
68
+ open: openDelay,
69
+ close: closeDelay,
70
+ }}
71
+ >
72
+ <Tooltip
73
+ // This is overridden by the group delay, kept here intentionally for testing purposes.
74
+ delay={{
75
+ open: 100,
76
+ close: 100,
77
+ }}
78
+ content={<Text size={1}>{content}</Text>}
79
+ padding={padding}
80
+ placement={placement}
81
+ portal={portal}
82
+ shadow={shadow}
83
+ >
84
+ <Button mode="bleed" text="Hover me" />
85
+ </Tooltip>
86
+ <Tooltip
87
+ // This is overridden by the group delay, kept here intentionally for testing purposes.
88
+ delay={{
89
+ open: 100,
90
+ close: 100,
91
+ }}
92
+ content={<Text size={1}>{content}</Text>}
93
+ padding={padding}
94
+ placement={placement}
95
+ portal={portal}
96
+ shadow={shadow}
97
+ >
98
+ <Button mode="bleed" text="Or me" />
99
+ </Tooltip>
100
+ </TooltipDelayGroupProvider>
101
+ </Flex>
102
+ </Stack>
28
103
  </Flex>
29
104
  </Card>
30
105
  )
@@ -1 +1,2 @@
1
1
  export * from './tooltip'
2
+ export * from './tooltipDelayGroup'
@@ -0,0 +1,278 @@
1
+ /** @jest-environment jsdom */
2
+ import {screen, act, fireEvent} from '@testing-library/react'
3
+ import '../../../test/mocks/resizeObserver.mock'
4
+ import '../../../test/mocks/matchMedia.mock'
5
+ import {render} from '../../../test'
6
+ import {Text, Button} from '../../primitives'
7
+ import {Tooltip, TooltipDelayGroupProvider} from '../tooltip'
8
+
9
+ describe('Tooltip', () => {
10
+ describe('Using same delay for open and close', () => {
11
+ it('should hide and show the tooltip content when hovered, with no delay', () => {
12
+ render(
13
+ <Tooltip content={<Text size={1}>{'Tooltip content'}</Text>} placement={'top'}>
14
+ <Button mode="bleed" text="Hover me" />
15
+ </Tooltip>,
16
+ )
17
+
18
+ const button = screen.getByText('Hover me')
19
+
20
+ // Validate tooltip content is not rendered
21
+ expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
22
+
23
+ fireEvent.mouseEnter(button)
24
+
25
+ // Validate tooltip content is rendered
26
+ screen.getByText('Tooltip content')
27
+
28
+ fireEvent.mouseOut(button)
29
+ // Validate tooltip content is not rendered anymore
30
+ expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
31
+ })
32
+ it('should support delays to show and hide the tooltip.', () => {
33
+ jest.useFakeTimers()
34
+ const delay = 200
35
+
36
+ render(
37
+ <Tooltip
38
+ content={<Text size={1}>{'Tooltip content'}</Text>}
39
+ placement={'top'}
40
+ delay={delay}
41
+ >
42
+ <Button mode="bleed" text="Hover me" />
43
+ </Tooltip>,
44
+ )
45
+
46
+ const button = screen.getByText('Hover me')
47
+
48
+ // Validate tooltip content is not rendered
49
+ expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
50
+
51
+ fireEvent.mouseEnter(button)
52
+
53
+ act(() => jest.advanceTimersByTime(delay / 2))
54
+ // Content should not be rendered yet
55
+ expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
56
+ act(() => jest.advanceTimersByTime(delay / 2))
57
+
58
+ // Validate tooltip content is rendered
59
+ screen.getByText('Tooltip content')
60
+
61
+ fireEvent.mouseOut(button)
62
+ // Validate tooltip content is still showing.
63
+ screen.getByText('Tooltip content')
64
+ act(() => jest.advanceTimersByTime(delay))
65
+ // Validate tooltip content is not rendered anymore
66
+ expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
67
+ })
68
+ it('should support different open and close delays to show and hide the tooltip.', () => {
69
+ jest.useFakeTimers()
70
+ const openDelay = 200
71
+ const closeDelay = 150
72
+
73
+ render(
74
+ <Tooltip
75
+ content={<Text size={1}>{'Tooltip content'}</Text>}
76
+ placement={'top'}
77
+ delay={{
78
+ open: openDelay,
79
+ close: closeDelay,
80
+ }}
81
+ >
82
+ <Button mode="bleed" text="Hover me" />
83
+ </Tooltip>,
84
+ )
85
+
86
+ const button = screen.getByText('Hover me')
87
+
88
+ // Validate tooltip content is not rendered
89
+ expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
90
+
91
+ fireEvent.mouseEnter(button)
92
+
93
+ act(() => jest.advanceTimersByTime(openDelay / 2))
94
+ // Content should not be rendered yet
95
+ expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
96
+ act(() => jest.advanceTimersByTime(openDelay / 2))
97
+
98
+ // Validate tooltip content is rendered
99
+ screen.getByText('Tooltip content')
100
+
101
+ fireEvent.mouseOut(button)
102
+ // Validate tooltip content is still showing.
103
+ screen.getByText('Tooltip content')
104
+ act(() => jest.advanceTimersByTime(closeDelay))
105
+ // Validate tooltip content is not rendered anymore
106
+ expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
107
+ })
108
+ })
109
+
110
+ describe('Using the <TooltipDelayGroupProvider />', () => {
111
+ it('should support groups with the same delay to open and close.', () => {
112
+ const delay = 150
113
+
114
+ jest.useFakeTimers()
115
+ render(
116
+ <TooltipDelayGroupProvider delay={delay}>
117
+ <Tooltip content={<Text size={1}>{'Tooltip 1'}</Text>} placement={'top'} delay={400}>
118
+ <Button mode="bleed" text="Button 1" />
119
+ </Tooltip>
120
+ <Tooltip
121
+ content={<Text size={1}>{'Tooltip 2'}</Text>}
122
+ placement={'top'}
123
+ delay={400} // This should be overridden by the group delay
124
+ >
125
+ <Button mode="bleed" text="Button 2" />
126
+ </Tooltip>
127
+ </TooltipDelayGroupProvider>,
128
+ )
129
+
130
+ const button1 = screen.getByText('Button 1')
131
+ const button2 = screen.getByText('Button 2')
132
+
133
+ // Validate tooltip content is not rendered
134
+ expect(screen.queryByText('Tooltip 1')).not.toBeInTheDocument()
135
+ expect(screen.queryByText('Tooltip 2')).not.toBeInTheDocument()
136
+
137
+ // Hovers on first button, it should show first tooltip only
138
+ fireEvent.mouseEnter(button1)
139
+ act(() => jest.advanceTimersByTime(delay / 2))
140
+ // Content should not be rendered yet, we have a delay of 150ms
141
+ expect(screen.queryByText('Tooltip 1')).not.toBeInTheDocument()
142
+ expect(screen.queryByText('Tooltip 2')).not.toBeInTheDocument()
143
+ act(() => jest.advanceTimersByTime(delay / 2))
144
+
145
+ // Validate Tooltip 1 is rendered
146
+ screen.getByText('Tooltip 1')
147
+ expect(screen.queryByText('Tooltip 2')).not.toBeInTheDocument()
148
+
149
+ // Hovers on second button.
150
+ fireEvent.mouseOut(button1)
151
+ fireEvent.mouseEnter(button2)
152
+
153
+ // Validate Tooltip 1 is not rendered, now tooltip 2 is open.
154
+ act(() => jest.advanceTimersByTime(1))
155
+ expect(screen.queryByText('Tooltip 1')).not.toBeInTheDocument()
156
+ screen.getByText('Tooltip 2')
157
+
158
+ // Validate tooltip content is not rendered anymore
159
+ fireEvent.mouseOut(button2)
160
+ act(() => jest.advanceTimersByTime(delay + 1))
161
+ expect(screen.queryByText('Tooltip 2')).not.toBeInTheDocument()
162
+
163
+ // Hovering again, should trigger the tooltip to show immediately, as the group is not deactivated yet
164
+ fireEvent.mouseEnter(button2)
165
+ act(() => jest.advanceTimersByTime(1))
166
+ screen.getByText('Tooltip 2')
167
+
168
+ // Validate tooltip content is not rendered anymore
169
+ fireEvent.mouseOut(button2)
170
+ act(() => jest.advanceTimersByTime(delay + 1))
171
+ expect(screen.queryByText('Tooltip 2')).not.toBeInTheDocument()
172
+
173
+ // Wait 200ms, the group is deactivated, hovering again should trigger the delay
174
+ act(() => jest.advanceTimersByTime(200))
175
+ fireEvent.mouseEnter(button2)
176
+ act(() => jest.advanceTimersByTime(delay / 2))
177
+ expect(screen.queryByText('Tooltip 2')).not.toBeInTheDocument()
178
+ act(() => jest.advanceTimersByTime(delay / 2))
179
+ screen.getByText('Tooltip 2')
180
+ })
181
+ it('should support groups with different open and close delay.', () => {
182
+ const openDelay = 250
183
+ const closeDelay = 150
184
+
185
+ jest.useFakeTimers()
186
+ render(
187
+ <TooltipDelayGroupProvider
188
+ delay={{
189
+ open: openDelay,
190
+ close: closeDelay,
191
+ }}
192
+ >
193
+ <Tooltip content={<Text size={1}>{'Tooltip 1'}</Text>} placement={'top'} delay={400}>
194
+ <Button mode="bleed" text="Button 1" />
195
+ </Tooltip>
196
+ <Tooltip
197
+ content={<Text size={1}>{'Tooltip 2'}</Text>}
198
+ placement={'top'}
199
+ delay={400} // This should be overridden by the group delay
200
+ >
201
+ <Button mode="bleed" text="Button 2" />
202
+ </Tooltip>
203
+ </TooltipDelayGroupProvider>,
204
+ )
205
+
206
+ const button1 = screen.getByText('Button 1')
207
+ const button2 = screen.getByText('Button 2')
208
+
209
+ // Validate tooltip content is not rendered
210
+ expect(screen.queryByText('Tooltip 1')).not.toBeInTheDocument()
211
+ expect(screen.queryByText('Tooltip 2')).not.toBeInTheDocument()
212
+
213
+ // Hovers on first button, it should show first tooltip only
214
+ fireEvent.mouseEnter(button1)
215
+ act(() => jest.advanceTimersByTime(openDelay / 2))
216
+ // Content should not be rendered yet, we have a delay of2150ms
217
+ expect(screen.queryByText('Tooltip 1')).not.toBeInTheDocument()
218
+ expect(screen.queryByText('Tooltip 2')).not.toBeInTheDocument()
219
+ act(() => jest.advanceTimersByTime(openDelay / 2))
220
+
221
+ // Validate Tooltip 1 is rendered
222
+ screen.getByText('Tooltip 1')
223
+ expect(screen.queryByText('Tooltip 2')).not.toBeInTheDocument()
224
+
225
+ // Hovers on second button.
226
+ fireEvent.mouseOut(button1)
227
+ fireEvent.mouseEnter(button2)
228
+
229
+ // Validate Tooltip 1 is not rendered, now tooltip 2 is open.
230
+ act(() => jest.advanceTimersByTime(1))
231
+ expect(screen.queryByText('Tooltip 1')).not.toBeInTheDocument()
232
+ screen.getByText('Tooltip 2')
233
+
234
+ // Validate tooltip content is not rendered anymore
235
+ fireEvent.mouseOut(button2)
236
+ act(() => jest.advanceTimersByTime(closeDelay + 1))
237
+ expect(screen.queryByText('Tooltip 2')).not.toBeInTheDocument()
238
+
239
+ // Hovering again, should trigger the tooltip to show immediately, as the group is not deactivated yet
240
+ fireEvent.mouseEnter(button2)
241
+ act(() => jest.advanceTimersByTime(1))
242
+ screen.getByText('Tooltip 2')
243
+
244
+ // Validate tooltip content is not rendered anymore
245
+ fireEvent.mouseOut(button2)
246
+ act(() => jest.advanceTimersByTime(closeDelay + 1))
247
+ expect(screen.queryByText('Tooltip 2')).not.toBeInTheDocument()
248
+
249
+ // Wait 200ms, the group is deactivated, hovering again should trigger the delay
250
+ act(() => jest.advanceTimersByTime(200))
251
+ fireEvent.mouseEnter(button2)
252
+ act(() => jest.advanceTimersByTime(openDelay / 2))
253
+ expect(screen.queryByText('Tooltip 2')).not.toBeInTheDocument()
254
+ act(() => jest.advanceTimersByTime(openDelay / 2))
255
+ screen.getByText('Tooltip 2')
256
+ })
257
+ it("should throw an error if it's nested inside another <TooltipDelayGroupProvider />", () => {
258
+ // eslint-disable-next-line no-console
259
+ console.error = jest.fn() // Silence console.error as we are actually expecting an error in this test.
260
+ expect(() => {
261
+ render(
262
+ <TooltipDelayGroupProvider delay={100}>
263
+ <TooltipDelayGroupProvider delay={100}>
264
+ <Tooltip content={<Text size={1}>{'Tooltip 1'}</Text>} placement={'top'} delay={400}>
265
+ <Button mode="bleed" text="Button 1" />
266
+ </Tooltip>
267
+ </TooltipDelayGroupProvider>
268
+ </TooltipDelayGroupProvider>,
269
+ )
270
+ }).toThrow(
271
+ 'TooltipDelayGroupProvider cannot be nested inside another TooltipDelayGroupProvider',
272
+ )
273
+
274
+ // Clean up the console.error mock
275
+ jest.clearAllMocks()
276
+ })
277
+ })
278
+ })
@@ -20,15 +20,19 @@ import {
20
20
  useState,
21
21
  CSSProperties,
22
22
  ForwardedRef,
23
+ useId,
23
24
  } from 'react'
24
25
  import styled from 'styled-components'
25
26
  import {FLOATING_STATIC_SIDES} from '../../constants'
26
27
  import {useArrayProp, useForwardedRef} from '../../hooks'
28
+ import {useDelayedState} from '../../hooks/useDelayedState'
27
29
  import {ThemeColorSchemeKey, useTheme} from '../../theme'
28
30
  import {Placement} from '../../types'
29
31
  import {Layer, LayerProps, Portal, useBoundaryElement} from '../../utils'
30
32
  import {Card} from '../card'
33
+ import {Delay} from '../types'
31
34
  import {TooltipArrow} from './tooltipArrow'
35
+ import {useTooltipDelayGroup} from './tooltipDelayGroup'
32
36
 
33
37
  /**
34
38
  * @public
@@ -46,6 +50,14 @@ export interface TooltipProps extends Omit<LayerProps, 'as'> {
46
50
  portal?: boolean | string
47
51
  scheme?: ThemeColorSchemeKey
48
52
  shadow?: number | number[]
53
+ /**
54
+ * @public Adds a delay to open or close the tooltip. Defaults to 0.
55
+ *
56
+ * If only a `number` is passed, it will be used for both opening and closing.
57
+ *
58
+ * If an object `{open: number; close:number}` is passed, it can be used to set different delays for each action.
59
+ */
60
+ delay?: Delay
49
61
  }
50
62
 
51
63
  const Root = styled(Layer)`
@@ -73,6 +85,7 @@ export const Tooltip = forwardRef(function Tooltip(
73
85
  scheme,
74
86
  shadow = 2,
75
87
  zOffset = theme.sanity.layer?.tooltip.zOffset,
88
+ delay,
76
89
  ...restProps
77
90
  } = props
78
91
  const fallbackPlacements = useArrayProp(fallbackPlacementsProp)
@@ -149,11 +162,45 @@ export const Tooltip = forwardRef(function Tooltip(
149
162
  return style
150
163
  }, [arrowX, arrowY, staticSide])
151
164
 
152
- const [isOpen, setIsOpen] = useState(false)
153
- const handleBlur = useCallback(() => setIsOpen(false), [])
154
- const handleFocus = useCallback(() => setIsOpen(true), [])
155
- const handleMouseEnter = useCallback(() => setIsOpen(true), [])
156
- const handleMouseLeave = useCallback(() => setIsOpen(false), [])
165
+ const tooltipId = useId()
166
+ const [isOpen, setIsOpen] = useDelayedState(false)
167
+ const delayGroupContext = useTooltipDelayGroup()
168
+ const showTooltip = isOpen || delayGroupContext?.openTooltipId === tooltipId
169
+
170
+ const isInsideGroup = delayGroupContext !== null
171
+ const openDelayProp = typeof delay === 'number' ? delay : delay?.open || 0
172
+ const closeDelayProp = typeof delay === 'number' ? delay : delay?.close || 0
173
+
174
+ const openDelay = isInsideGroup ? delayGroupContext.openDelay : openDelayProp
175
+ const closeDelay = isInsideGroup ? delayGroupContext.closeDelay : closeDelayProp
176
+
177
+ const handleIsOpenChange = useCallback(
178
+ (open: boolean) => {
179
+ if (isInsideGroup) {
180
+ // When it's inside a group, the open or close status will be handled by the group.
181
+ if (open) {
182
+ delayGroupContext.setIsGroupActive(open, openDelay)
183
+ delayGroupContext.setOpenTooltipId(tooltipId, openDelay)
184
+ } else {
185
+ const minimumGroupDeactivateDelay = 200 // We should provide some delay to allow the user to reach the next tooltip.
186
+ const groupDeactivateDelay =
187
+ closeDelay > minimumGroupDeactivateDelay ? closeDelay : minimumGroupDeactivateDelay
188
+
189
+ delayGroupContext.setIsGroupActive(open, groupDeactivateDelay)
190
+ delayGroupContext.setOpenTooltipId(null, closeDelay)
191
+ }
192
+ } else {
193
+ // When it's not inside a group, the open or close status will be handled by the tooltip itself.
194
+ setIsOpen(open, open ? openDelay : closeDelay)
195
+ }
196
+ },
197
+ [isInsideGroup, delayGroupContext, openDelay, tooltipId, closeDelay, setIsOpen],
198
+ )
199
+
200
+ const handleBlur = useCallback(() => handleIsOpenChange(false), [handleIsOpenChange])
201
+ const handleFocus = useCallback(() => handleIsOpenChange(true), [handleIsOpenChange])
202
+ const handleMouseEnter = useCallback(() => handleIsOpenChange(true), [handleIsOpenChange])
203
+ const handleMouseLeave = useCallback(() => handleIsOpenChange(false), [handleIsOpenChange])
157
204
 
158
205
  // Detect whether the mouse is moving outside of the reference element. This is sometimes
159
206
  // necessary, because the tooltip might not always close as it should (e.g. when clicking
@@ -169,7 +216,7 @@ export const Tooltip = forwardRef(function Tooltip(
169
216
  (event.target instanceof Node && referenceElement.contains(event.target))
170
217
 
171
218
  if (!isHoveringReference) {
172
- setIsOpen(false)
219
+ handleIsOpenChange(false)
173
220
  window.removeEventListener('mousemove', handleWindowMouseMove)
174
221
  }
175
222
  }
@@ -179,17 +226,17 @@ export const Tooltip = forwardRef(function Tooltip(
179
226
  return () => {
180
227
  window.removeEventListener('mousemove', handleWindowMouseMove)
181
228
  }
182
- }, [isOpen, referenceElement])
229
+ }, [isOpen, referenceElement, handleIsOpenChange])
183
230
 
184
231
  // Close when `disabled` changes to `true`
185
232
  useEffect(() => {
186
- if (disabled) setIsOpen(false)
187
- }, [disabled])
233
+ if (disabled) handleIsOpenChange(false)
234
+ }, [disabled, handleIsOpenChange])
188
235
 
189
236
  // Close when `content` changes to falsy
190
237
  useEffect(() => {
191
- if (!content) setIsOpen(false)
192
- }, [content])
238
+ if (!content) handleIsOpenChange(false)
239
+ }, [content, handleIsOpenChange])
193
240
 
194
241
  // Update reference
195
242
  useEffect(() => refs.setReference(referenceElement), [referenceElement, refs])
@@ -268,7 +315,7 @@ export const Tooltip = forwardRef(function Tooltip(
268
315
  <>
269
316
  {child}
270
317
 
271
- {isOpen && (
318
+ {showTooltip && (
272
319
  <>
273
320
  {portal ? (
274
321
  <Portal __unstable_name={typeof portal === 'string' ? portal : undefined}>
@@ -0,0 +1,4 @@
1
+ export * from './useTooltipDelayGroup'
2
+ export * from './tooltipDelayGroupContext'
3
+ export * from './tooltipDelayGroupProvider'
4
+ export * from './types'
@@ -0,0 +1,13 @@
1
+ import {createContext} from 'react'
2
+ import {globalScope} from '../../../lib/globalScope'
3
+ import {TooltipDelayGroupContextValue} from './types'
4
+
5
+ const key = Symbol.for('@sanity/ui/context/tooltipDelayGroup')
6
+
7
+ globalScope[key] = globalScope[key] || createContext<TooltipDelayGroupContextValue | null>(null)
8
+
9
+ /**
10
+ * @beta
11
+ */
12
+ export const TooltipDelayGroupContext: React.Context<TooltipDelayGroupContextValue | null> =
13
+ globalScope[key]