@sanity/ui 1.7.11 → 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.11",
3
+ "version": "1.8.0",
4
4
  "sideEffects": false,
5
5
  "types": "./dist/index.d.ts",
6
6
  "source": "./src/index.ts",
@@ -66,13 +66,13 @@
66
66
  "@juggle/resize-observer": "^3.4.0",
67
67
  "@sanity/pkg-utils": "^2.4.8",
68
68
  "@sanity/semantic-release-preset": "^4.1.3",
69
- "@sanity/ui-workshop": "^1.2.10",
69
+ "@sanity/ui-workshop": "^1.2.11",
70
70
  "@testing-library/dom": "^9.3.1",
71
71
  "@testing-library/jest-dom": "^5.17.0",
72
72
  "@testing-library/react": "^14.0.0",
73
73
  "@types/jest": "^29.5.4",
74
74
  "@types/jest-axe": "^3.5.5",
75
- "@types/node": "^20.5.3",
75
+ "@types/node": "^20.5.4",
76
76
  "@types/react": "^18.2.21",
77
77
  "@types/react-dom": "^18.2.7",
78
78
  "@types/react-is": "^18.2.1",
@@ -95,9 +95,9 @@
95
95
  "eslint-plugin-react-hooks": "^4.6.0",
96
96
  "http-server": "^14.1.1",
97
97
  "husky": "^8.0.3",
98
- "jest": "^29.6.3",
98
+ "jest": "^29.6.4",
99
99
  "jest-axe": "^8.0.0",
100
- "jest-environment-jsdom": "^29.6.3",
100
+ "jest-environment-jsdom": "^29.6.4",
101
101
  "lint-staged": "^14.0.1",
102
102
  "module-alias": "^2.2.3",
103
103
  "npm-run-all": "^4.1.5",
@@ -105,8 +105,9 @@
105
105
  "react": "^18.2.0",
106
106
  "react-dom": "^18.2.0",
107
107
  "react-is": "^18.2.0",
108
+ "refractor": "^4.8.1",
108
109
  "rimraf": "^5.0.1",
109
- "semantic-release": "^21.0.9",
110
+ "semantic-release": "^21.1.0",
110
111
  "start-server-and-test": "^2.0.0",
111
112
  "styled-components": "^6.0.7",
112
113
  "tsconfig-paths": "^4.2.0",
@@ -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
+ })