@sanity/ui 2.7.1-canary.2 → 2.8.1
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.d.mts +25 -53
- package/dist/index.d.ts +25 -53
- package/dist/index.esm.js +46 -6
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +45 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +46 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +34 -26
- package/src/core/hooks/index.ts +1 -0
- package/src/core/hooks/useClickOutside.test.tsx +0 -109
- package/src/core/hooks/useClickOutside.ts +71 -103
- package/src/core/hooks/useClickOutsideEvent.test.tsx +116 -0
- package/src/core/hooks/useClickOutsideEvent.ts +72 -0
- package/src/core/primitives/popover/__workshop__/AlignedStory.tsx +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/ui",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.1",
|
|
4
4
|
"keywords": [
|
|
5
5
|
"sanity",
|
|
6
6
|
"ui",
|
|
@@ -51,6 +51,32 @@
|
|
|
51
51
|
"src",
|
|
52
52
|
"theme.js"
|
|
53
53
|
],
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "run-s clean pkg:build pkg:check figma:pkg:build",
|
|
56
|
+
"clean": "rimraf .workshop dist",
|
|
57
|
+
"commit": "cz",
|
|
58
|
+
"cypress:dev": "run-p dev cypress:open",
|
|
59
|
+
"cypress:open": "cypress open",
|
|
60
|
+
"cypress:run": "cypress run",
|
|
61
|
+
"dev": "run-p storybook:dev workshop:dev",
|
|
62
|
+
"figma:pkg:build": "cd figma && pnpm build",
|
|
63
|
+
"format": "prettier --write --cache --ignore-unknown .",
|
|
64
|
+
"lint": "eslint . --ext .cjs,.js,.jsx,.mjs,.ts,.tsx",
|
|
65
|
+
"pkg:build": "pkg build --strict",
|
|
66
|
+
"pkg:check": "pkg --strict",
|
|
67
|
+
"prepare": "husky install",
|
|
68
|
+
"prepack": "pnpm build",
|
|
69
|
+
"release": "semantic-release",
|
|
70
|
+
"storybook:build": "storybook build",
|
|
71
|
+
"storybook:dev": "storybook dev -p 6006",
|
|
72
|
+
"test": "jest",
|
|
73
|
+
"test:browser": "start-server-and-test 'run-s workshop:build workshop:start' http://localhost:1337 'run-s cypress:run'",
|
|
74
|
+
"ts:check": "tsc",
|
|
75
|
+
"watch": "pkg watch --strict",
|
|
76
|
+
"workshop:build": "workshop build",
|
|
77
|
+
"workshop:dev": "workshop dev",
|
|
78
|
+
"workshop:start": "http-server -a localhost -c-0 -p 1337 -s -P http://localhost:1337/index.html? dist"
|
|
79
|
+
},
|
|
54
80
|
"commitlint": {
|
|
55
81
|
"extends": [
|
|
56
82
|
"@commitlint/config-conventional"
|
|
@@ -169,37 +195,19 @@
|
|
|
169
195
|
"react-is": "^18",
|
|
170
196
|
"styled-components": "^5.2 || ^6"
|
|
171
197
|
},
|
|
198
|
+
"packageManager": "pnpm@9.5.0",
|
|
172
199
|
"engines": {
|
|
173
200
|
"node": ">=14.0.0"
|
|
174
201
|
},
|
|
175
202
|
"publishConfig": {
|
|
176
203
|
"access": "public"
|
|
177
204
|
},
|
|
205
|
+
"pnpm": {
|
|
206
|
+
"overrides": {
|
|
207
|
+
"conventional-changelog-conventionalcommits": ">= 8.0.0"
|
|
208
|
+
}
|
|
209
|
+
},
|
|
178
210
|
"esm.sh": {
|
|
179
211
|
"bundle": false
|
|
180
|
-
},
|
|
181
|
-
"scripts": {
|
|
182
|
-
"build": "run-s clean pkg:build pkg:check figma:pkg:build",
|
|
183
|
-
"clean": "rimraf .workshop dist",
|
|
184
|
-
"commit": "cz",
|
|
185
|
-
"cypress:dev": "run-p dev cypress:open",
|
|
186
|
-
"cypress:open": "cypress open",
|
|
187
|
-
"cypress:run": "cypress run",
|
|
188
|
-
"dev": "run-p storybook:dev workshop:dev",
|
|
189
|
-
"figma:pkg:build": "cd figma && pnpm build",
|
|
190
|
-
"format": "prettier --write --cache --ignore-unknown .",
|
|
191
|
-
"lint": "eslint . --ext .cjs,.js,.jsx,.mjs,.ts,.tsx",
|
|
192
|
-
"pkg:build": "pkg build --strict",
|
|
193
|
-
"pkg:check": "pkg --strict",
|
|
194
|
-
"release": "semantic-release",
|
|
195
|
-
"storybook:build": "storybook build",
|
|
196
|
-
"storybook:dev": "storybook dev -p 6006",
|
|
197
|
-
"test": "jest",
|
|
198
|
-
"test:browser": "start-server-and-test 'run-s workshop:build workshop:start' http://localhost:1337 'run-s cypress:run'",
|
|
199
|
-
"ts:check": "tsc",
|
|
200
|
-
"watch": "pkg watch --strict",
|
|
201
|
-
"workshop:build": "workshop build",
|
|
202
|
-
"workshop:dev": "workshop dev",
|
|
203
|
-
"workshop:start": "http-server -a localhost -c-0 -p 1337 -s -P http://localhost:1337/index.html? dist"
|
|
204
212
|
}
|
|
205
|
-
}
|
|
213
|
+
}
|
package/src/core/hooks/index.ts
CHANGED
|
@@ -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,137 +11,106 @@ export type ClickOutsideListener = (event: MouseEvent) => void
|
|
|
12
11
|
*/
|
|
13
12
|
export type ClickOutsideElements = (HTMLElement | null | (HTMLElement | null)[])[]
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
53
|
-
*
|
|
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
|
-
*
|
|
39
|
+
* do:
|
|
62
40
|
* ```tsx
|
|
63
41
|
* const buttonRef = useRef()
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
|
76
|
-
elementsArg: ClickOutsideElements,
|
|
47
|
+
listener: ClickOutsideListener,
|
|
48
|
+
elementsArg: ClickOutsideElements = EMPTY_ARRAY,
|
|
77
49
|
boundaryElement?: HTMLElement | null,
|
|
78
|
-
):
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
66
|
+
for (const el of prevElements) {
|
|
67
|
+
if (!nextElements.includes(el)) {
|
|
68
|
+
setElements(nextElements)
|
|
69
|
+
elementsRef.current = nextElements
|
|
111
70
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const elements = resolvedElements.flat()
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
}
|
|
116
74
|
|
|
117
|
-
for (const el of
|
|
118
|
-
if (!el)
|
|
75
|
+
for (const el of nextElements) {
|
|
76
|
+
if (!prevElements.includes(el)) {
|
|
77
|
+
setElements(nextElements)
|
|
78
|
+
elementsRef.current = nextElements
|
|
119
79
|
|
|
120
|
-
if (target === el || el.contains(target)) {
|
|
121
80
|
return
|
|
122
81
|
}
|
|
123
82
|
}
|
|
83
|
+
}, [element, elementsArg])
|
|
124
84
|
|
|
125
|
-
|
|
126
|
-
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!listener) return undefined
|
|
127
87
|
|
|
128
|
-
|
|
88
|
+
const handleWindowMouseDown = (evt: MouseEvent) => {
|
|
89
|
+
const target = evt.target
|
|
129
90
|
|
|
130
|
-
|
|
131
|
-
|
|
91
|
+
if (!(target instanceof Node)) {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
132
94
|
|
|
133
|
-
|
|
95
|
+
if (boundaryElement && !boundaryElement.contains(target)) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
134
98
|
|
|
135
|
-
|
|
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)
|
|
136
109
|
|
|
137
110
|
return () => {
|
|
138
|
-
|
|
111
|
+
window.removeEventListener('mousedown', handleWindowMouseDown)
|
|
139
112
|
}
|
|
140
|
-
}, [
|
|
113
|
+
}, [boundaryElement, listener, elements])
|
|
141
114
|
|
|
142
|
-
|
|
143
|
-
* Only return the legacy setElement function if the elementsArg is an array
|
|
144
|
-
*/
|
|
145
|
-
if (Array.isArray(elementsArg)) {
|
|
146
|
-
return setLegacyElement as UseClickOutsideSetElement
|
|
147
|
-
}
|
|
115
|
+
return setElement
|
|
148
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,
|
|
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
|
-
|
|
43
|
+
useClickOutsideEvent(
|
|
44
44
|
() => setOpen(false),
|
|
45
45
|
() => [buttonElementRef.current, popoverElementRef.current],
|
|
46
46
|
)
|