@sanity/ui 2.7.1-canary.0 → 2.7.1-canary.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.
@@ -1,4 +1,5 @@
1
- import {useEffect, useRef, useState} from 'react'
1
+ import {useEffect, useState} from 'react'
2
+ import {useEffectEvent} from 'use-effect-event'
2
3
  import {EMPTY_ARRAY} from '../constants'
3
4
 
4
5
  /**
@@ -6,94 +7,142 @@ import {EMPTY_ARRAY} from '../constants'
6
7
  */
7
8
  export type ClickOutsideListener = (event: MouseEvent) => void
8
9
 
9
- function _getElements(
10
- element: HTMLElement | null,
11
- elementsArg: Array<HTMLElement | HTMLElement[] | null>,
12
- ): HTMLElement[] {
13
- const ret = [element]
14
-
15
- for (const el of elementsArg) {
16
- if (Array.isArray(el)) {
17
- ret.push(...el)
18
- } else {
19
- ret.push(el)
20
- }
21
- }
10
+ /**
11
+ * @public
12
+ */
13
+ export type ClickOutsideElements = (HTMLElement | null | (HTMLElement | null)[])[]
22
14
 
23
- return ret.filter(Boolean) as HTMLElement[]
24
- }
15
+ /**
16
+ * @public
17
+ * @deprecated - use your own `const [element, setElement] = useState(null)` logic instead
18
+ */
19
+ export type UseClickOutsideSetElement = (el: HTMLElement | null) => void
25
20
 
26
21
  /**
27
22
  * @public
28
23
  */
29
24
  export function useClickOutside(
30
- listener: ClickOutsideListener,
31
- elementsArg: Array<HTMLElement | HTMLElement[] | null> = EMPTY_ARRAY,
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,
44
+ elementsArg: ClickOutsideElements,
45
+ boundaryElement: () => HTMLElement | null,
46
+ ): UseClickOutsideSetElement
47
+ /**
48
+ * @public
49
+ * @deprecated
50
+ * Instead of:
51
+ * ```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
+ * )
60
+ * ```
61
+ * Use:
62
+ * ```tsx
63
+ * 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
+ * )
72
+ * ```
73
+ */
74
+ export function useClickOutside(
75
+ listener: ClickOutsideListener | false | undefined,
76
+ elementsArg: ClickOutsideElements,
32
77
  boundaryElement?: HTMLElement | null,
33
- ): (el: HTMLElement | null) => void {
34
- const [element, setElement] = useState<HTMLElement | null>(null)
35
- const [elements, setElements] = useState(() => _getElements(element, elementsArg))
36
- const elementsRef = useRef(elements)
37
-
38
- useEffect(() => {
39
- const prevElements = elementsRef.current
40
- const nextElements = _getElements(element, elementsArg)
41
-
42
- if (prevElements.length !== nextElements.length) {
43
- setElements(nextElements)
44
- elementsRef.current = nextElements
78
+ ): UseClickOutsideSetElement
45
79
 
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) {
46
97
  return
47
98
  }
48
99
 
49
- for (const el of prevElements) {
50
- if (!nextElements.includes(el)) {
51
- setElements(nextElements)
52
- elementsRef.current = nextElements
100
+ const target = evt.target
53
101
 
54
- return
55
- }
102
+ if (!(target instanceof Node)) {
103
+ return
56
104
  }
57
105
 
58
- for (const el of nextElements) {
59
- if (!prevElements.includes(el)) {
60
- setElements(nextElements)
61
- elementsRef.current = nextElements
62
-
63
- return
64
- }
106
+ const resolvedBoundaryElement =
107
+ typeof boundaryElement === 'function' ? boundaryElement() : boundaryElement
108
+ if (resolvedBoundaryElement && !resolvedBoundaryElement.contains(target)) {
109
+ return
65
110
  }
66
- }, [element, elementsArg])
67
111
 
68
- useEffect(() => {
69
- if (!listener) return undefined
112
+ const resolvedElements = Array.isArray(elementsArg)
113
+ ? [legacyElement, ...elementsArg]
114
+ : elementsArg()
115
+ const elements = resolvedElements.flat()
70
116
 
71
- const handleWindowMouseDown = (evt: MouseEvent) => {
72
- const target = evt.target
117
+ for (const el of elements) {
118
+ if (!el) continue
73
119
 
74
- if (!(target instanceof Node)) {
120
+ if (target === el || el.contains(target)) {
75
121
  return
76
122
  }
123
+ }
77
124
 
78
- if (boundaryElement && !boundaryElement.contains(target)) {
79
- return
80
- }
125
+ listener(evt)
126
+ })
81
127
 
82
- for (const el of elements) {
83
- if (target === el || el.contains(target)) {
84
- return
85
- }
86
- }
128
+ const hasListener = Boolean(listener)
87
129
 
88
- listener(evt)
89
- }
130
+ useEffect(() => {
131
+ if (!hasListener) return undefined
90
132
 
91
- window.addEventListener('mousedown', handleWindowMouseDown)
133
+ const handleEvent = (evt: MouseEvent) => onEvent(evt)
134
+
135
+ document.addEventListener('mousedown', handleEvent)
92
136
 
93
137
  return () => {
94
- window.removeEventListener('mousedown', handleWindowMouseDown)
138
+ document.removeEventListener('mousedown', handleEvent)
95
139
  }
96
- }, [boundaryElement, listener, elements])
140
+ }, [hasListener, onEvent])
97
141
 
98
- return setElement
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
+ }
99
148
  }
@@ -18,8 +18,8 @@ export function useMatchMedia(
18
18
  */
19
19
  let MEDIA_QUERY_CACHE: MediaQueryList | undefined
20
20
 
21
- function getMatchMedia(): MediaQueryList {
22
- if (MEDIA_QUERY_CACHE === undefined) {
21
+ const getMatchMedia = (): MediaQueryList => {
22
+ if (!MEDIA_QUERY_CACHE) {
23
23
  // As this function is only called during `subscribe` and `getSnapshot`, we can assume that the
24
24
  // the `window` global is available and we're in a browser environment
25
25
  MEDIA_QUERY_CACHE = window.matchMedia(mediaQueryString)
@@ -36,13 +36,7 @@ export function useMatchMedia(
36
36
 
37
37
  return () => matchMedia.removeEventListener('change', onStoreChange)
38
38
  },
39
- getSnapshot: () => {
40
- try {
41
- return getMatchMedia().matches
42
- } catch (err) {
43
- throw new Error(`Failed to match media query: ${mediaQueryString}`, {cause: err})
44
- }
45
- },
39
+ getSnapshot: () => getMatchMedia().matches,
46
40
  }
47
41
  }, [mediaQueryString])
48
42
 
@@ -1,7 +1,7 @@
1
1
  import {EllipsisVerticalIcon} from '@sanity/icons'
2
2
  import {Button, Card, Flex, Popover, Text, useClickOutside} from '@sanity/ui'
3
3
  import {useBoolean, useSelect} from '@sanity/ui-workshop'
4
- import {useCallback, useState} from 'react'
4
+ import {useCallback, useRef, useState} from 'react'
5
5
  import {
6
6
  WORKSHOP_FLEX_ALIGN_OPTIONS,
7
7
  WORKSHOP_FLEX_JUSTIFY_OPTIONS,
@@ -20,8 +20,8 @@ export default function AlignedStory() {
20
20
 
21
21
  const [open, setOpen] = useState(false)
22
22
  const [boundaryElement, setBoundaryElement] = useState<HTMLDivElement | null>(null)
23
- const [buttonElement, setButtonElement] = useState<HTMLButtonElement | null>(null)
24
- const [popoverElement, setPopoverElement] = useState<HTMLDivElement | null>(null)
23
+ const buttonElementRef = useRef<HTMLButtonElement | null>(null)
24
+ const popoverElementRef = useRef<HTMLDivElement | null>(null)
25
25
 
26
26
  const content = (
27
27
  <Text>
@@ -39,9 +39,11 @@ export default function AlignedStory() {
39
39
  )
40
40
 
41
41
  const handleToggleOpen = useCallback(() => setOpen((v) => !v), [])
42
- const handleClose = useCallback(() => setOpen(false), [])
43
42
 
44
- useClickOutside(handleClose, [buttonElement, popoverElement])
43
+ useClickOutside(
44
+ () => setOpen(false),
45
+ () => [buttonElementRef.current, popoverElementRef.current],
46
+ )
45
47
 
46
48
  return (
47
49
  <Card height="fill" padding={[4, 5, 6]} sizing="border" tone="transparent">
@@ -56,14 +58,14 @@ export default function AlignedStory() {
56
58
  padding={3}
57
59
  portal={portal}
58
60
  placement={placement}
59
- ref={setPopoverElement}
61
+ ref={popoverElementRef}
60
62
  width={width}
61
63
  >
62
64
  <Button
63
65
  icon={EllipsisVerticalIcon}
64
66
  mode="bleed"
65
67
  onClick={handleToggleOpen}
66
- ref={setButtonElement}
68
+ ref={buttonElementRef}
67
69
  selected={open}
68
70
  />
69
71
  </Popover>