@sanity/ui 2.8.0 → 2.8.2

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": "2.8.0",
3
+ "version": "2.8.2",
4
4
  "keywords": [
5
5
  "sanity",
6
6
  "ui",
@@ -161,7 +161,7 @@
161
161
  "eslint-plugin-boundaries": "^4.2.2",
162
162
  "eslint-plugin-import": "^2.29.1",
163
163
  "eslint-plugin-jsx-a11y": "^6.9.0",
164
- "eslint-plugin-prettier": "^5.1.3",
164
+ "eslint-plugin-prettier": "^5.2.1",
165
165
  "eslint-plugin-react": "^7.34.4",
166
166
  "eslint-plugin-react-compiler": "0.0.0-experimental-a97cca1-20240529",
167
167
  "eslint-plugin-react-hooks": "^4.6.2",
@@ -183,7 +183,7 @@
183
183
  "semantic-release": "^24.0.0",
184
184
  "start-server-and-test": "^2.0.4",
185
185
  "storybook": "^8.2.4",
186
- "styled-components": "^6.1.11",
186
+ "styled-components": "^6.1.12",
187
187
  "tsconfig-paths": "^4.2.0",
188
188
  "typescript": "5.5.3",
189
189
  "vite": "^5.3.4",
@@ -216,18 +216,21 @@ const DialogCard = forwardRef(function DialogCard(
216
216
  )
217
217
 
218
218
  useClickOutside(
219
- (event: MouseEvent) => {
220
- if (!isTopLayer || !onClickOutside) return
219
+ useCallback(
220
+ (event) => {
221
+ if (!isTopLayer || !onClickOutside) return
221
222
 
222
- const target = event.target as Node | null
223
+ const target = event.target as Node | null
223
224
 
224
- if (target && !isTargetWithinScope(boundaryElement, portalElement, target)) {
225
- // Ignore clicks outside of the scope
226
- return
227
- }
225
+ if (target && !isTargetWithinScope(boundaryElement, portalElement, target)) {
226
+ // Ignore clicks outside of the scope
227
+ return
228
+ }
228
229
 
229
- onClickOutside()
230
- },
230
+ onClickOutside()
231
+ },
232
+ [boundaryElement, isTopLayer, onClickOutside, portalElement],
233
+ ),
231
234
  [rootElement],
232
235
  )
233
236
 
@@ -93,7 +93,13 @@ export const Menu = forwardRef(function Menu(
93
93
  }, [activeIndex, onItemSelect])
94
94
 
95
95
  // Close menu when clicking outside
96
- useClickOutside((event) => isTopLayer && onClickOutside && onClickOutside(event), [rootElement])
96
+ useClickOutside(
97
+ useCallback(
98
+ (event) => isTopLayer && onClickOutside && onClickOutside(event),
99
+ [isTopLayer, onClickOutside],
100
+ ),
101
+ [rootElement],
102
+ )
97
103
 
98
104
  // Close menu when pressing Escape
99
105
  useGlobalKeyDown(
@@ -1,5 +1,6 @@
1
1
  export * from './useArrayProp'
2
2
  export * from './useClickOutside'
3
+ export * from './useClickOutsideEvent'
3
4
  export * from './useCustomValidity'
4
5
  export * from './useElementRect'
5
6
  export * from './useElementSize'
@@ -8,115 +8,6 @@ import {useEffect, useRef, useState} from 'react'
8
8
  import {useClickOutside} from './useClickOutside'
9
9
 
10
10
  describe('useClickOutside', () => {
11
- describe('current API', () => {
12
- /**
13
- * This suite demonstrates the new API for `useClickOutside` that were introduced in v2.8.0
14
- */
15
-
16
- it('calls the handler when clicking outside of the array of elements', async () => {
17
- const user = userEvent.setup()
18
- const handler = jest.fn()
19
-
20
- const TestComponent = () => {
21
- const buttonRef = useRef<HTMLButtonElement | null>(null)
22
- const popoverRef = useRef<HTMLDivElement | null>(null)
23
-
24
- useClickOutside(handler, () => [buttonRef.current, popoverRef.current])
25
-
26
- return (
27
- <>
28
- <button data-testid="button" ref={buttonRef} />
29
- <div data-testid="popover" ref={popoverRef} />
30
- <div data-testid="outside" />
31
- </>
32
- )
33
- }
34
-
35
- render(<TestComponent />)
36
-
37
- await user.click(screen.getByTestId('button'))
38
- await user.click(screen.getByTestId('popover'))
39
- expect(handler).not.toHaveBeenCalled()
40
-
41
- await user.click(screen.getByTestId('outside'))
42
- expect(handler).toHaveBeenCalledTimes(1)
43
- })
44
-
45
- it('the elements array flattens nested arrays one level deep', async () => {
46
- const user = userEvent.setup()
47
- const handler = jest.fn()
48
-
49
- const TestComponent = () => {
50
- const buttonRef = useRef<HTMLButtonElement | null>(null)
51
- const popoverRef = useRef<HTMLDivElement | null>(null)
52
-
53
- useClickOutside(handler, () => [
54
- null,
55
- [null, buttonRef.current],
56
- [popoverRef.current, null],
57
- null,
58
- ])
59
-
60
- return (
61
- <>
62
- <button data-testid="button" ref={buttonRef} />
63
- <div data-testid="popover" ref={popoverRef} />
64
- <div data-testid="outside" />
65
- </>
66
- )
67
- }
68
-
69
- render(<TestComponent />)
70
-
71
- await user.click(screen.getByTestId('button'))
72
- await user.click(screen.getByTestId('popover'))
73
- expect(handler).not.toHaveBeenCalled()
74
-
75
- await user.click(screen.getByTestId('outside'))
76
- expect(handler).toHaveBeenCalledTimes(1)
77
- })
78
-
79
- it('it can set a boundary to scope outside click events', async () => {
80
- const user = userEvent.setup()
81
- const handler = jest.fn()
82
-
83
- const TestComponent = () => {
84
- const buttonRef = useRef<HTMLButtonElement | null>(null)
85
- const popoverRef = useRef<HTMLDivElement | null>(null)
86
- const boundaryRef = useRef<HTMLDivElement | null>(null)
87
-
88
- useClickOutside(
89
- handler,
90
- () => [buttonRef.current, popoverRef.current],
91
- () => boundaryRef.current,
92
- )
93
-
94
- return (
95
- <>
96
- <div ref={boundaryRef}>
97
- <button data-testid="button" ref={buttonRef} />
98
- <div data-testid="popover" ref={popoverRef} />
99
- <div data-testid="inside" />
100
- </div>
101
- <div data-testid="outside" />
102
- </>
103
- )
104
- }
105
-
106
- render(<TestComponent />)
107
-
108
- await user.click(screen.getByTestId('button'))
109
- await user.click(screen.getByTestId('popover'))
110
- // Since it's outside the boundary it should be ignored
111
- await user.click(screen.getByTestId('outside'))
112
- expect(handler).not.toHaveBeenCalled()
113
-
114
- await user.click(screen.getByTestId('inside'))
115
- expect(handler).toHaveBeenCalledTimes(1)
116
- })
117
- })
118
- })
119
- describe('legacy API', () => {
120
11
  /**
121
12
  * Specifying the elements array directly:
122
13
  * useClickOutside(handler, [buttonElement, popoverElement])
@@ -1,5 +1,4 @@
1
- import {useEffect, useState} from 'react'
2
- import {useEffectEvent} from 'use-effect-event'
1
+ import {useEffect, useRef, useState} from 'react'
3
2
  import {EMPTY_ARRAY} from '../constants'
4
3
 
5
4
  /**
@@ -12,138 +11,106 @@ export type ClickOutsideListener = (event: MouseEvent) => void
12
11
  */
13
12
  export type ClickOutsideElements = (HTMLElement | null | (HTMLElement | null)[])[]
14
13
 
15
- /**
16
- * @public
17
- * @deprecated - use your own `const [element, setElement] = useState(null)` logic instead
18
- */
19
- export type UseClickOutsideSetElement = (el: HTMLElement | null) => void
20
-
21
- /**
22
- * @public
23
- */
24
- export function useClickOutside(
25
- listener: ClickOutsideListener | false | undefined,
26
- elementsArg: () => ClickOutsideElements,
27
- boundaryElement?: () => HTMLElement | null,
28
- ): void
29
- /**
30
- * @public
31
- * @deprecated - change `useClickOutside(handler, () => [...], boundary)` to `useClickOutside(handler, () => [...], () => boundary)`
32
- */
33
- export function useClickOutside(
34
- listener: ClickOutsideListener | false | undefined,
35
- elementsArg: () => ClickOutsideElements,
36
- boundaryElement: HTMLElement | null,
37
- ): void
38
- /**
39
- * @public
40
- * @deprecated - change `useClickOutside(handler, [...], () => boundary)` to `useClickOutside(handler, () => [...], () => boundary)`
41
- */
42
- export function useClickOutside(
43
- listener: ClickOutsideListener | false | undefined,
14
+ function _getElements(
15
+ element: HTMLElement | null,
44
16
  elementsArg: ClickOutsideElements,
45
- boundaryElement: () => HTMLElement | null,
46
- ): UseClickOutsideSetElement
17
+ ): HTMLElement[] {
18
+ const ret = [element]
19
+
20
+ for (const el of elementsArg) {
21
+ if (Array.isArray(el)) {
22
+ ret.push(...el)
23
+ } else {
24
+ ret.push(el)
25
+ }
26
+ }
27
+
28
+ return ret.filter(Boolean) as HTMLElement[]
29
+ }
30
+
47
31
  /**
48
32
  * @public
49
- * @deprecated
50
- * Instead of:
33
+ * @deprecated replaced by the new `useClickOutsideEvent` hook, instead of:
51
34
  * ```tsx
52
- * const buttonRef = useRef(null)
53
- * const setElement = useClickOutside(() => {}, [buttonRef.current])
54
- * return (
55
- * <>
56
- * <button ref={buttonRef} />
57
- * {open && <div ref={setElement} />}
58
- * </>
59
- * )
35
+ * const [button, setButtonElement] = useState(null)
36
+ * useClickOutside((event) => {}, [button])
37
+ * return <button ref={setButtonElement} />
60
38
  * ```
61
- * Use:
39
+ * do:
62
40
  * ```tsx
63
41
  * const buttonRef = useRef()
64
- * const [element, setElement] = useState(null)
65
- * useClickOutside(() => {}, () => [buttonRef.current, element])
66
- * return (
67
- * <>
68
- * <button ref={buttonRef} />
69
- * {open && <div ref={setElement} />}
70
- * </>
71
- * )
42
+ * useClickOutsideEvent((event) => {}, () => [buttonRef.current])
43
+ * return <button ref={buttonRef} />
72
44
  * ```
73
45
  */
74
46
  export function useClickOutside(
75
- listener: ClickOutsideListener | false | undefined,
76
- elementsArg: ClickOutsideElements,
47
+ listener: ClickOutsideListener,
48
+ elementsArg: ClickOutsideElements = EMPTY_ARRAY,
77
49
  boundaryElement?: HTMLElement | null,
78
- ): UseClickOutsideSetElement
50
+ ): (el: HTMLElement | null) => void {
51
+ const [element, setElement] = useState<HTMLElement | null>(null)
52
+ const [elements, setElements] = useState(() => _getElements(element, elementsArg))
53
+ const elementsRef = useRef(elements)
79
54
 
80
- /**
81
- * @public
82
- */
83
- export function useClickOutside(
84
- listener: ClickOutsideListener | false | undefined,
85
- elementsArg: ClickOutsideElements | (() => ClickOutsideElements) = EMPTY_ARRAY,
86
- boundaryElement?: HTMLElement | null | (() => HTMLElement | null),
87
- ): UseClickOutsideSetElement | void {
88
- /** @deprecated */
89
- const [legacyElement, setLegacyElement] = useState<HTMLElement | null>(null)
90
-
91
- /**
92
- * The `useEffectEvent` hook allow us to always see the latest value of `listener`, `elementsArg` and `boundaryElement` without needing to
93
- * juggle `useState`, `useRef` and `useState` to make sure the `mousedown` event listener isn't constantly being added and removed.
94
- */
95
- const onEvent = useEffectEvent((evt: MouseEvent) => {
96
- if (!listener) {
97
- return
98
- }
55
+ useEffect(() => {
56
+ const prevElements = elementsRef.current
57
+ const nextElements = _getElements(element, elementsArg)
99
58
 
100
- const target = evt.target
59
+ if (prevElements.length !== nextElements.length) {
60
+ setElements(nextElements)
61
+ elementsRef.current = nextElements
101
62
 
102
- if (!(target instanceof Node)) {
103
63
  return
104
64
  }
105
65
 
106
- const resolvedBoundaryElement =
107
- typeof boundaryElement === 'function' ? boundaryElement() : boundaryElement
66
+ for (const el of prevElements) {
67
+ if (!nextElements.includes(el)) {
68
+ setElements(nextElements)
69
+ elementsRef.current = nextElements
108
70
 
109
- if (resolvedBoundaryElement && !resolvedBoundaryElement.contains(target)) {
110
- return
71
+ return
72
+ }
111
73
  }
112
74
 
113
- const resolvedElements = Array.isArray(elementsArg)
114
- ? [legacyElement, ...elementsArg]
115
- : elementsArg()
116
- const elements = resolvedElements.flat()
117
-
118
- for (const el of elements) {
119
- if (!el) continue
75
+ for (const el of nextElements) {
76
+ if (!prevElements.includes(el)) {
77
+ setElements(nextElements)
78
+ elementsRef.current = nextElements
120
79
 
121
- if (target === el || el.contains(target)) {
122
80
  return
123
81
  }
124
82
  }
83
+ }, [element, elementsArg])
125
84
 
126
- listener(evt)
127
- })
85
+ useEffect(() => {
86
+ if (!listener) return undefined
128
87
 
129
- const hasListener = Boolean(listener)
88
+ const handleWindowMouseDown = (evt: MouseEvent) => {
89
+ const target = evt.target
130
90
 
131
- useEffect(() => {
132
- if (!hasListener) return undefined
91
+ if (!(target instanceof Node)) {
92
+ return
93
+ }
133
94
 
134
- const handleEvent = (evt: MouseEvent) => onEvent(evt)
95
+ if (boundaryElement && !boundaryElement.contains(target)) {
96
+ return
97
+ }
135
98
 
136
- document.addEventListener('mousedown', handleEvent)
99
+ for (const el of elements) {
100
+ if (target === el || el.contains(target)) {
101
+ return
102
+ }
103
+ }
104
+
105
+ listener(evt)
106
+ }
107
+
108
+ window.addEventListener('mousedown', handleWindowMouseDown)
137
109
 
138
110
  return () => {
139
- document.removeEventListener('mousedown', handleEvent)
111
+ window.removeEventListener('mousedown', handleWindowMouseDown)
140
112
  }
141
- }, [hasListener, onEvent])
113
+ }, [boundaryElement, listener, elements])
142
114
 
143
- /**
144
- * Only return the legacy setElement function if the elementsArg is an array
145
- */
146
- if (Array.isArray(elementsArg)) {
147
- return setLegacyElement as UseClickOutsideSetElement
148
- }
115
+ return setElement
149
116
  }
@@ -0,0 +1,116 @@
1
+ /** @jest-environment jsdom */
2
+ /* eslint-disable padding-line-between-statements, react/display-name */
3
+
4
+ import {render, screen} from '@testing-library/react'
5
+ import userEvent from '@testing-library/user-event'
6
+ import {useRef} from 'react'
7
+
8
+ import {useClickOutsideEvent} from './useClickOutsideEvent'
9
+
10
+ describe('useClickOutsideEvent', () => {
11
+ /**
12
+ * This suite demonstrates the new hook `useClickOutsideEvent` that replaces `useClickOutside`
13
+ */
14
+
15
+ it('calls the handler when clicking outside of the array of elements', async () => {
16
+ const user = userEvent.setup()
17
+ const handler = jest.fn()
18
+
19
+ const TestComponent = () => {
20
+ const buttonRef = useRef<HTMLButtonElement | null>(null)
21
+ const popoverRef = useRef<HTMLDivElement | null>(null)
22
+
23
+ useClickOutsideEvent(handler, () => [buttonRef.current, popoverRef.current])
24
+
25
+ return (
26
+ <>
27
+ <button data-testid="button" ref={buttonRef} />
28
+ <div data-testid="popover" ref={popoverRef} />
29
+ <div data-testid="outside" />
30
+ </>
31
+ )
32
+ }
33
+
34
+ render(<TestComponent />)
35
+
36
+ await user.click(screen.getByTestId('button'))
37
+ await user.click(screen.getByTestId('popover'))
38
+ expect(handler).not.toHaveBeenCalled()
39
+
40
+ await user.click(screen.getByTestId('outside'))
41
+ expect(handler).toHaveBeenCalledTimes(1)
42
+ })
43
+
44
+ it('the elements array flattens nested arrays one level deep', async () => {
45
+ const user = userEvent.setup()
46
+ const handler = jest.fn()
47
+
48
+ const TestComponent = () => {
49
+ const buttonRef = useRef<HTMLButtonElement | null>(null)
50
+ const popoverRef = useRef<HTMLDivElement | null>(null)
51
+
52
+ useClickOutsideEvent(handler, () => [
53
+ null,
54
+ [null, buttonRef.current],
55
+ [popoverRef.current, null],
56
+ null,
57
+ ])
58
+
59
+ return (
60
+ <>
61
+ <button data-testid="button" ref={buttonRef} />
62
+ <div data-testid="popover" ref={popoverRef} />
63
+ <div data-testid="outside" />
64
+ </>
65
+ )
66
+ }
67
+
68
+ render(<TestComponent />)
69
+
70
+ await user.click(screen.getByTestId('button'))
71
+ await user.click(screen.getByTestId('popover'))
72
+ expect(handler).not.toHaveBeenCalled()
73
+
74
+ await user.click(screen.getByTestId('outside'))
75
+ expect(handler).toHaveBeenCalledTimes(1)
76
+ })
77
+
78
+ it('it can set a boundary to scope outside click events', async () => {
79
+ const user = userEvent.setup()
80
+ const handler = jest.fn()
81
+
82
+ const TestComponent = () => {
83
+ const buttonRef = useRef<HTMLButtonElement | null>(null)
84
+ const popoverRef = useRef<HTMLDivElement | null>(null)
85
+ const boundaryRef = useRef<HTMLDivElement | null>(null)
86
+
87
+ useClickOutsideEvent(
88
+ handler,
89
+ () => [buttonRef.current, popoverRef.current],
90
+ () => boundaryRef.current,
91
+ )
92
+
93
+ return (
94
+ <>
95
+ <div ref={boundaryRef}>
96
+ <button data-testid="button" ref={buttonRef} />
97
+ <div data-testid="popover" ref={popoverRef} />
98
+ <div data-testid="inside" />
99
+ </div>
100
+ <div data-testid="outside" />
101
+ </>
102
+ )
103
+ }
104
+
105
+ render(<TestComponent />)
106
+
107
+ await user.click(screen.getByTestId('button'))
108
+ await user.click(screen.getByTestId('popover'))
109
+ // Since it's outside the boundary it should be ignored
110
+ await user.click(screen.getByTestId('outside'))
111
+ expect(handler).not.toHaveBeenCalled()
112
+
113
+ await user.click(screen.getByTestId('inside'))
114
+ expect(handler).toHaveBeenCalledTimes(1)
115
+ })
116
+ })
@@ -0,0 +1,72 @@
1
+ import {useEffect} from 'react'
2
+ import {useEffectEvent} from 'use-effect-event'
3
+ import {EMPTY_ARRAY} from '../constants'
4
+
5
+ /**
6
+ * @public
7
+ */
8
+ export type ClickOutsideEventListener = (event: MouseEvent) => void
9
+
10
+ /**
11
+ * @public
12
+ */
13
+ export type ClickOutsideEventElements = (HTMLElement | null | (HTMLElement | null)[])[]
14
+
15
+ /**
16
+ * @public
17
+ */
18
+ export function useClickOutsideEvent(
19
+ listener: ClickOutsideEventListener | false | undefined,
20
+ elementsArg: () => ClickOutsideEventElements = () => EMPTY_ARRAY,
21
+ boundaryElement?: () => HTMLElement | null,
22
+ ): void {
23
+ /**
24
+ * The `useEffectEvent` hook allow us to always see the latest value of `listener`, `elementsArg` and `boundaryElement` without needing to
25
+ * juggle `useState`, `useRef` and `useState` to make sure the `mousedown` event listener isn't constantly being added and removed.
26
+ */
27
+ const onEvent = useEffectEvent((evt: MouseEvent) => {
28
+ if (!listener) {
29
+ return
30
+ }
31
+
32
+ const target = evt.target
33
+
34
+ if (!(target instanceof Node)) {
35
+ return
36
+ }
37
+
38
+ const resolvedBoundaryElement =
39
+ typeof boundaryElement === 'function' ? boundaryElement() : boundaryElement
40
+
41
+ if (resolvedBoundaryElement && !resolvedBoundaryElement.contains(target)) {
42
+ return
43
+ }
44
+
45
+ const resolvedElements = Array.isArray(elementsArg) ? elementsArg : elementsArg()
46
+ const elements = resolvedElements.flat()
47
+
48
+ for (const el of elements) {
49
+ if (!el) continue
50
+
51
+ if (target === el || el.contains(target)) {
52
+ return
53
+ }
54
+ }
55
+
56
+ listener(evt)
57
+ })
58
+
59
+ const hasListener = Boolean(listener)
60
+
61
+ useEffect(() => {
62
+ if (!hasListener) return undefined
63
+
64
+ const handleEvent = (evt: MouseEvent) => onEvent(evt)
65
+
66
+ document.addEventListener('mousedown', handleEvent)
67
+
68
+ return () => {
69
+ document.removeEventListener('mousedown', handleEvent)
70
+ }
71
+ }, [hasListener, onEvent])
72
+ }
@@ -1,5 +1,5 @@
1
1
  import {EllipsisVerticalIcon} from '@sanity/icons'
2
- import {Button, Card, Flex, Popover, Text, useClickOutside} from '@sanity/ui'
2
+ import {Button, Card, Flex, Popover, Text, useClickOutsideEvent} from '@sanity/ui'
3
3
  import {useBoolean, useSelect} from '@sanity/ui-workshop'
4
4
  import {useCallback, useRef, useState} from 'react'
5
5
  import {
@@ -40,7 +40,7 @@ export default function AlignedStory() {
40
40
 
41
41
  const handleToggleOpen = useCallback(() => setOpen((v) => !v), [])
42
42
 
43
- useClickOutside(
43
+ useClickOutsideEvent(
44
44
  () => setOpen(false),
45
45
  () => [buttonElementRef.current, popoverElementRef.current],
46
46
  )