@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/dist/index.cjs.mjs +3 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.esm.js +99 -25
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +101 -24
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/hooks/useDelayed.test.ts +66 -0
- package/src/hooks/useDelayedState.ts +29 -0
- package/src/primitives/tooltip/__workshop__/props.tsx +86 -11
- package/src/primitives/tooltip/index.ts +1 -0
- package/src/primitives/tooltip/tooltip.test.tsx +278 -0
- package/src/primitives/tooltip/tooltip.tsx +59 -12
- package/src/primitives/tooltip/tooltipDelayGroup/index.ts +4 -0
- package/src/primitives/tooltip/tooltipDelayGroup/tooltipDelayGroupContext.tsx +13 -0
- package/src/primitives/tooltip/tooltipDelayGroup/tooltipDelayGroupProvider.tsx +61 -0
- package/src/primitives/tooltip/tooltipDelayGroup/types.ts +11 -0
- package/src/primitives/tooltip/tooltipDelayGroup/useTooltipDelayGroup.ts +12 -0
- package/src/primitives/types.ts +10 -0
package/package.json
CHANGED
|
@@ -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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
<
|
|
27
|
-
|
|
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
|
)
|
|
@@ -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
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
{
|
|
318
|
+
{showTooltip && (
|
|
272
319
|
<>
|
|
273
320
|
{portal ? (
|
|
274
321
|
<Portal __unstable_name={typeof portal === 'string' ? portal : undefined}>
|
|
@@ -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]
|