@sanity/ui 2.6.6 → 2.6.7-canary.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.
Files changed (36) hide show
  1. package/dist/index.d.mts +42 -6
  2. package/dist/index.d.ts +42 -6
  3. package/dist/index.esm.js +75 -142
  4. package/dist/index.esm.js.map +1 -1
  5. package/dist/index.js +74 -141
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +75 -142
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +26 -34
  10. package/src/core/components/breadcrumbs/breadcrumbs.tsx +15 -6
  11. package/src/core/components/dialog/dialog.tsx +12 -21
  12. package/src/core/components/menu/menu.tsx +11 -18
  13. package/src/core/components/menu/menuButton.tsx +1 -1
  14. package/src/core/components/menu/menuContext.ts +1 -1
  15. package/src/core/components/menu/useMenuController.ts +11 -17
  16. package/src/core/components/toast/styles.ts +2 -1
  17. package/src/core/components/toast/useToast.ts +1 -0
  18. package/src/core/components/tree/tree.tsx +1 -0
  19. package/src/core/components/tree/treeItem.tsx +1 -0
  20. package/src/core/helpers/focus.ts +1 -0
  21. package/src/core/helpers/scroll.ts +1 -0
  22. package/src/core/hooks/index.ts +3 -2
  23. package/src/core/hooks/useClickOutside.ts +38 -72
  24. package/src/core/hooks/useElementSize.ts +1 -0
  25. package/src/core/hooks/useMatchMedia.ts +46 -0
  26. package/src/core/hooks/useMediaIndex/useMediaIndex.ts +2 -10
  27. package/src/core/hooks/usePrefersDark.ts +10 -55
  28. package/src/core/hooks/usePrefersReducedMotion.ts +10 -56
  29. package/src/core/primitives/popover/__workshop__/AlignedStory.tsx +9 -7
  30. package/src/core/primitives/tooltip/__workshop__/customPortal.tsx +8 -6
  31. package/src/core/primitives/tooltip/tooltipDelayGroup/tooltipDelayGroupProvider.tsx +1 -0
  32. package/src/core/utils/layer/layerProvider.tsx +1 -0
  33. package/src/core/utils/portal/__workshop__/named.tsx +10 -8
  34. package/src/core/utils/portal/portalProvider.tsx +2 -3
  35. package/src/core/hooks/_internal/index.ts +0 -1
  36. package/src/core/hooks/_internal/useUnique.ts +0 -34
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/ui",
3
- "version": "2.6.6",
3
+ "version": "2.6.7-canary.0",
4
4
  "keywords": [
5
5
  "sanity",
6
6
  "ui",
@@ -51,32 +51,6 @@
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
- "prepublishOnly": "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
- },
80
54
  "commitlint": {
81
55
  "extends": [
82
56
  "@commitlint/config-conventional"
@@ -194,19 +168,37 @@
194
168
  "react-is": "^18",
195
169
  "styled-components": "^5.2 || ^6"
196
170
  },
197
- "packageManager": "pnpm@9.5.0",
198
171
  "engines": {
199
172
  "node": ">=14.0.0"
200
173
  },
201
174
  "publishConfig": {
202
175
  "access": "public"
203
176
  },
204
- "pnpm": {
205
- "overrides": {
206
- "conventional-changelog-conventionalcommits": ">= 8.0.0"
207
- }
208
- },
209
177
  "esm.sh": {
210
178
  "bundle": false
179
+ },
180
+ "scripts": {
181
+ "build": "run-s clean pkg:build pkg:check figma:pkg:build",
182
+ "clean": "rimraf .workshop dist",
183
+ "commit": "cz",
184
+ "cypress:dev": "run-p dev cypress:open",
185
+ "cypress:open": "cypress open",
186
+ "cypress:run": "cypress run",
187
+ "dev": "run-p storybook:dev workshop:dev",
188
+ "figma:pkg:build": "cd figma && pnpm build",
189
+ "format": "prettier --write --cache --ignore-unknown .",
190
+ "lint": "eslint . --ext .cjs,.js,.jsx,.mjs,.ts,.tsx",
191
+ "pkg:build": "pkg build --strict",
192
+ "pkg:check": "pkg --strict",
193
+ "release": "semantic-release",
194
+ "storybook:build": "storybook build",
195
+ "storybook:dev": "storybook dev -p 6006",
196
+ "test": "jest",
197
+ "test:browser": "start-server-and-test 'run-s workshop:build workshop:start' http://localhost:1337 'run-s cypress:run'",
198
+ "ts:check": "tsc",
199
+ "watch": "pkg watch --strict",
200
+ "workshop:build": "workshop build",
201
+ "workshop:dev": "workshop dev",
202
+ "workshop:start": "http-server -a localhost -c-0 -p 1337 -s -P http://localhost:1337/index.html? dist"
211
203
  }
212
- }
204
+ }
@@ -1,4 +1,13 @@
1
- import {Children, forwardRef, Fragment, isValidElement, useCallback, useMemo, useState} from 'react'
1
+ import {
2
+ Children,
3
+ forwardRef,
4
+ Fragment,
5
+ isValidElement,
6
+ useCallback,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from 'react'
2
11
  import {useArrayProp, useClickOutside} from '../../hooks'
3
12
  import {Box, Popover, Stack, Text} from '../../primitives'
4
13
  import {ExpandButton, Root} from './breadcrumbs.styles'
@@ -22,13 +31,13 @@ export const Breadcrumbs = forwardRef(function Breadcrumbs(
22
31
  const {children, maxLength, separator, space: spaceRaw = 2, ...restProps} = props
23
32
  const space = useArrayProp(spaceRaw)
24
33
  const [open, setOpen] = useState(false)
25
- const [expandElement, setExpandElement] = useState<HTMLButtonElement | null>(null)
26
- const [popoverElement, setPopoverElement] = useState<HTMLDivElement | null>(null)
34
+ const expandElementRef = useRef<HTMLButtonElement | null>(null)
35
+ const popoverElementRef = useRef<HTMLDivElement | null>(null)
27
36
 
28
37
  const collapse = useCallback(() => setOpen(false), [])
29
38
  const expand = useCallback(() => setOpen(true), [])
30
39
 
31
- useClickOutside(collapse, [expandElement, popoverElement])
40
+ useClickOutside(collapse, () => [expandElementRef.current, popoverElementRef.current])
32
41
 
33
42
  const rawItems = useMemo(() => Children.toArray(children).filter(isValidElement), [children])
34
43
 
@@ -52,14 +61,14 @@ export const Breadcrumbs = forwardRef(function Breadcrumbs(
52
61
  open={open}
53
62
  placement="top"
54
63
  portal
55
- ref={setPopoverElement}
64
+ ref={popoverElementRef}
56
65
  >
57
66
  <ExpandButton
58
67
  fontSize={1}
59
68
  mode="bleed"
60
69
  onClick={open ? collapse : expand}
61
70
  padding={1}
62
- ref={setExpandElement}
71
+ ref={expandElementRef}
63
72
  selected={open}
64
73
  text="…"
65
74
  />
@@ -1,6 +1,6 @@
1
1
  import {CloseIcon} from '@sanity/icons'
2
2
  import {ThemeColorSchemeKey} from '@sanity/ui/theme'
3
- import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'
3
+ import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef} from 'react'
4
4
  import {styled} from 'styled-components'
5
5
  import {
6
6
  containsOrEqualsElement,
@@ -170,7 +170,6 @@ const DialogCard = forwardRef(function DialogCard(
170
170
  const shadow = useArrayProp(shadowProp)
171
171
  const width = useArrayProp(widthProp)
172
172
  const ref = useRef<HTMLDivElement | null>(null)
173
- const [rootElement, setRootElement] = useState<HTMLDivElement | null>(null)
174
173
  const contentRef = useRef<HTMLDivElement | null>(null)
175
174
  const layer = useLayer()
176
175
  const {isTopLayer} = layer
@@ -216,32 +215,24 @@ const DialogCard = forwardRef(function DialogCard(
216
215
  )
217
216
 
218
217
  useClickOutside(
219
- useCallback(
220
- (event: MouseEvent) => {
221
- if (!isTopLayer || !onClickOutside) return
218
+ (event: MouseEvent | TouchEvent) => {
219
+ if (!isTopLayer || !onClickOutside) return
222
220
 
223
- const target = event.target as Node | null
221
+ const target = event.target as Node | null
224
222
 
225
- if (target && !isTargetWithinScope(boundaryElement, portalElement, target)) {
226
- // Ignore clicks outside of the scope
227
- return
228
- }
223
+ if (target && !isTargetWithinScope(boundaryElement, portalElement, target)) {
224
+ // Ignore clicks outside of the scope
225
+ return
226
+ }
229
227
 
230
- onClickOutside()
231
- },
232
- [boundaryElement, isTopLayer, onClickOutside, portalElement],
233
- ),
234
- [rootElement],
228
+ onClickOutside()
229
+ },
230
+ () => [ref.current],
235
231
  )
236
232
 
237
- const setRef = useCallback((el: HTMLDivElement | null) => {
238
- setRootElement(el)
239
- ref.current = el
240
- }, [])
241
-
242
233
  return (
243
234
  <DialogContainer data-ui="DialogCard" width={width}>
244
- <DialogCardRoot radius={radius} ref={setRef} scheme={scheme} shadow={shadow}>
235
+ <DialogCardRoot radius={radius} ref={ref} scheme={scheme} shadow={shadow}>
245
236
  <DialogLayout direction="column">
246
237
  {showHeader && (
247
238
  <DialogHeader>
@@ -19,7 +19,7 @@ export interface MenuProps extends ResponsivePaddingProps {
19
19
  * @deprecated Use `shouldFocus="last"` instead.
20
20
  */
21
21
  focusLast?: boolean
22
- onClickOutside?: (event: MouseEvent) => void
22
+ onClickOutside?: (event: MouseEvent | TouchEvent) => void
23
23
  onEscape?: () => void
24
24
  onItemClick?: () => void
25
25
  onItemSelect?: (index: number) => void
@@ -75,16 +75,18 @@ export const Menu = forwardRef(function Menu(
75
75
  handleItemMouseLeave,
76
76
  handleKeyDown,
77
77
  mount,
78
- rootElement,
79
- setRootElement,
80
- } = useMenuController({onKeyDown, originElement, shouldFocus})
78
+ } = useMenuController({onKeyDown, originElement, shouldFocus, rootElementRef: ref})
81
79
 
82
80
  const handleRefChange = useCallback(
83
81
  (el: HTMLDivElement | null) => {
84
- setRootElement(el)
85
82
  ref.current = el
83
+
84
+ // Register root element (for nested menus)
85
+ if (ref.current && registerElement) {
86
+ registerElement(ref.current)
87
+ }
86
88
  },
87
- [setRootElement],
89
+ [registerElement],
88
90
  )
89
91
 
90
92
  // Trigger `onItemSelect` when active index changes
@@ -94,11 +96,8 @@ export const Menu = forwardRef(function Menu(
94
96
 
95
97
  // Close menu when clicking outside
96
98
  useClickOutside(
97
- useCallback(
98
- (event) => isTopLayer && onClickOutside && onClickOutside(event),
99
- [isTopLayer, onClickOutside],
100
- ),
101
- [rootElement],
99
+ (event) => isTopLayer && onClickOutside && onClickOutside(event),
100
+ () => [ref.current],
102
101
  )
103
102
 
104
103
  // Close menu when pressing Escape
@@ -116,13 +115,7 @@ export const Menu = forwardRef(function Menu(
116
115
  ),
117
116
  )
118
117
 
119
- // Register root element (for nested menus)
120
- useEffect(() => {
121
- if (!rootElement || !registerElement) return
122
-
123
- return registerElement(rootElement)
124
- }, [registerElement, rootElement])
125
-
118
+ // @TODO split out into separate contexts
126
119
  const value: MenuContextValue = useMemo(
127
120
  () => ({
128
121
  version: 0.0,
@@ -140,7 +140,7 @@ export const MenuButton = forwardRef(function MenuButton(
140
140
  }, [])
141
141
 
142
142
  const handleMenuClickOutside = useCallback(
143
- (event: MouseEvent) => {
143
+ (event: MouseEvent | TouchEvent) => {
144
144
  const target = event.target
145
145
 
146
146
  if (!(target instanceof Node)) {
@@ -5,7 +5,7 @@ export interface MenuContextValue {
5
5
  activeElement: HTMLElement | null
6
6
  activeIndex: number
7
7
  mount: (element: HTMLElement | null, selected?: boolean) => () => void
8
- onClickOutside?: (event: MouseEvent) => void
8
+ onClickOutside?: (event: MouseEvent | TouchEvent) => void
9
9
  onEscape?: () => void
10
10
  onItemClick?: () => void
11
11
  onItemMouseEnter?: (event: React.MouseEvent<HTMLElement>) => void
@@ -1,4 +1,4 @@
1
- import {useCallback, useEffect, useRef, useState} from 'react'
1
+ import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
2
2
  import {_getFocusableElements, _sortElements} from './helpers'
3
3
 
4
4
  /**
@@ -11,8 +11,6 @@ export interface MenuController {
11
11
  handleItemMouseLeave: () => void
12
12
  handleKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
13
13
  mount: (element: HTMLElement | null, selected?: boolean) => () => void
14
- rootElement: HTMLDivElement | null
15
- setRootElement: (el: HTMLDivElement | null) => void
16
14
  }
17
15
 
18
16
  /**
@@ -24,14 +22,14 @@ export function useMenuController(props: {
24
22
  onKeyDown?: React.KeyboardEventHandler
25
23
  originElement?: HTMLElement | null
26
24
  shouldFocus: 'first' | 'last' | null
25
+ rootElementRef: React.MutableRefObject<HTMLDivElement | null>
27
26
  }): MenuController {
28
- const {onKeyDown, originElement, shouldFocus} = props
27
+ const {onKeyDown, originElement, shouldFocus, rootElementRef} = props
29
28
  const elementsRef = useRef<HTMLElement[]>([])
30
- const [rootElement, setRootElement] = useState<HTMLDivElement | null>(null)
31
29
  const [activeIndex, _setActiveIndex] = useState(-1)
32
30
  const activeIndexRef = useRef(activeIndex)
33
- const activeElement = elementsRef.current[activeIndex] || null
34
- const mounted = Boolean(rootElement)
31
+ const activeElement = useMemo(() => elementsRef.current[activeIndex] || null, [activeIndex])
32
+ const mounted = Boolean(rootElementRef.current)
35
33
 
36
34
  const setActiveIndex = useCallback((nextActiveIndex: number) => {
37
35
  _setActiveIndex(nextActiveIndex)
@@ -44,7 +42,7 @@ export function useMenuController(props: {
44
42
 
45
43
  if (elementsRef.current.indexOf(element) === -1) {
46
44
  elementsRef.current.push(element)
47
- _sortElements(rootElement, elementsRef.current)
45
+ _sortElements(rootElementRef.current, elementsRef.current)
48
46
  }
49
47
 
50
48
  if (selected) {
@@ -61,7 +59,7 @@ export function useMenuController(props: {
61
59
  }
62
60
  }
63
61
  },
64
- [rootElement, setActiveIndex],
62
+ [rootElementRef, setActiveIndex],
65
63
  )
66
64
 
67
65
  const handleKeyDown = useCallback(
@@ -179,17 +177,15 @@ export function useMenuController(props: {
179
177
  // which would be incorrect when the user hovers over a gap
180
178
  // between two menu items or a menu divider.
181
179
  setActiveIndex(-2)
182
- rootElement?.focus()
183
- }, [setActiveIndex, rootElement])
180
+ rootElementRef.current?.focus()
181
+ }, [rootElementRef, setActiveIndex])
184
182
 
185
183
  // Set focus on the currently active element
186
184
  useEffect(() => {
187
185
  if (!mounted) return
188
186
 
189
187
  const rafId = window.requestAnimationFrame(() => {
190
- const _activeIndex = activeIndexRef.current
191
-
192
- if (_activeIndex === -1) {
188
+ if (activeIndex === -1) {
193
189
  if (shouldFocus === 'first') {
194
190
  const focusableElements = _getFocusableElements(elementsRef.current)
195
191
  const el = focusableElements[0]
@@ -217,7 +213,7 @@ export function useMenuController(props: {
217
213
  return
218
214
  }
219
215
 
220
- const element = elementsRef.current[_activeIndex] || null
216
+ const element = elementsRef.current[activeIndex] || null
221
217
 
222
218
  element?.focus()
223
219
  })
@@ -234,7 +230,5 @@ export function useMenuController(props: {
234
230
  handleItemMouseLeave,
235
231
  handleKeyDown,
236
232
  mount,
237
- rootElement,
238
- setRootElement,
239
233
  }
240
234
  }
@@ -14,11 +14,12 @@ const loadingAnimation = keyframes`
14
14
  }
15
15
  100% {
16
16
  width: 100%;
17
- }
17
+ }
18
18
  `
19
19
 
20
20
  const LOADING_BAR_HEIGHT = 2
21
21
 
22
+ // @TODO get rid of $duration modifier, set data attribute instead and use stable selector
22
23
  export function rootStyles(
23
24
  props: {$duration?: number; tone: ThemeColorStateToneKey} & ThemeProps,
24
25
  ): ReturnType<typeof css> {
@@ -13,6 +13,7 @@ export function useToast(): ToastContextValue {
13
13
  throw new Error('useToast(): missing context value')
14
14
  }
15
15
 
16
+ // @TODO context and hooks doesn't really work like this, there will never be a mismatch between the provider and the consumer, we can remove these version specifiers
16
17
  // NOTE: This check is for future-compatiblity
17
18
  // - If the value is not an object, it’s not compatible with the current version
18
19
  // - If the value is an object, but doesn’t have `version: 0.0`, it’s not compatible with the current version
@@ -84,6 +84,7 @@ export const Tree = memo(
84
84
  })
85
85
  }, [])
86
86
 
87
+ // @TODO split out into separate contexts
87
88
  const contextValue: TreeContextValue = useMemo(
88
89
  () => ({
89
90
  version: 0.0,
@@ -76,6 +76,7 @@ export const TreeItem = memo(function TreeItem(
76
76
  const focused = tree.focusedElement === rootRef.current
77
77
  const expanded = itemState?.expanded === undefined ? expandedProp : itemState?.expanded || false
78
78
  const tabIndex = tree.focusedElement && tree.focusedElement === rootRef.current ? 0 : -1
79
+ // @TODO split out into separate contexts
79
80
  const contextValue = useMemo(
80
81
  () => ({...tree, level: tree.level + 1, path: itemPath}),
81
82
  [itemPath, tree],
@@ -15,6 +15,7 @@ import {
15
15
  * @internal
16
16
  */
17
17
  export function _hasFocus(element: HTMLElement): boolean {
18
+ // @TODO verify this is not called during render
18
19
  return Boolean(document.activeElement) && element.contains(document.activeElement)
19
20
  }
20
21
 
@@ -4,6 +4,7 @@
4
4
  export function _isScrollable(el: Node): boolean {
5
5
  if (!(el instanceof Element)) return false
6
6
 
7
+ // @TODO check if this is called during render
7
8
  const style = window.getComputedStyle(el)
8
9
 
9
10
  return (
@@ -1,10 +1,11 @@
1
1
  export * from './useArrayProp'
2
2
  export * from './useClickOutside'
3
+ export * from './useCustomValidity'
3
4
  export * from './useElementRect'
4
5
  export * from './useElementSize'
6
+ export * from './useForwardedRef'
5
7
  export * from './useGlobalKeyDown'
8
+ export * from './useMatchMedia'
6
9
  export * from './useMediaIndex'
7
10
  export * from './usePrefersDark'
8
11
  export * from './usePrefersReducedMotion'
9
- export * from './useForwardedRef'
10
- export * from './useCustomValidity'
@@ -1,99 +1,65 @@
1
- import {useEffect, useRef, useState} from 'react'
1
+ import {useEffect} from 'react'
2
+ import {useEffectEvent} from 'use-effect-event'
2
3
  import {EMPTY_ARRAY} from '../constants'
3
4
 
4
5
  /**
5
6
  * @public
6
7
  */
7
- export type ClickOutsideListener = (event: MouseEvent) => void
8
-
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
- }
22
-
23
- return ret.filter(Boolean) as HTMLElement[]
24
- }
8
+ export type ClickOutsideListener = (event: MouseEvent | TouchEvent) => void
25
9
 
26
10
  /**
11
+ * Use the callback version of `elementsArg` if you're using `useRef` to handle elements:
12
+ * ```tsx
13
+ * useClickOutside(
14
+ * () => {},
15
+ * () => [ref.current],
16
+ * )
17
+ *
27
18
  * @public
28
19
  */
29
20
  export function useClickOutside(
30
21
  listener: ClickOutsideListener,
31
- elementsArg: Array<HTMLElement | HTMLElement[] | null> = EMPTY_ARRAY,
22
+ elementsArg:
23
+ | Array<HTMLElement | HTMLElement[] | null>
24
+ | (() => Array<HTMLElement | HTMLElement[] | null>) = EMPTY_ARRAY,
32
25
  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
45
-
26
+ ): void {
27
+ /**
28
+ * The `useEffectEvent` hook allow us to always see the latest value of `listener`, `elementsArg` and `boundaryElement` without needing to
29
+ * juggle `useState`, `useRef` and `useState` to make sure the `mousedown` event listener isn't constantly being added and removed.
30
+ */
31
+ const eventHandler = useEffectEvent((evt: MouseEvent | TouchEvent) => {
32
+ const target = evt.target
33
+
34
+ if (!(target instanceof Node)) {
46
35
  return
47
36
  }
48
37
 
49
- for (const el of prevElements) {
50
- if (!nextElements.includes(el)) {
51
- setElements(nextElements)
52
- elementsRef.current = nextElements
53
-
54
- return
55
- }
56
- }
57
-
58
- for (const el of nextElements) {
59
- if (!prevElements.includes(el)) {
60
- setElements(nextElements)
61
- elementsRef.current = nextElements
62
-
63
- return
64
- }
38
+ if (boundaryElement && !boundaryElement.contains(target)) {
39
+ return
65
40
  }
66
- }, [element, elementsArg])
67
41
 
68
- useEffect(() => {
69
- if (!listener) return undefined
70
-
71
- const handleWindowMouseDown = (evt: MouseEvent) => {
72
- const target = evt.target
42
+ const resolvedElements = Array.isArray(elementsArg) ? elementsArg : elementsArg()
43
+ const elements = resolvedElements.flat()
73
44
 
74
- if (!(target instanceof Node)) {
75
- return
76
- }
45
+ for (const el of elements) {
46
+ if (!el) continue
77
47
 
78
- if (boundaryElement && !boundaryElement.contains(target)) {
48
+ if (target === el || el.contains(target)) {
79
49
  return
80
50
  }
81
-
82
- for (const el of elements) {
83
- if (target === el || el.contains(target)) {
84
- return
85
- }
86
- }
87
-
88
- listener(evt)
89
51
  }
90
52
 
91
- window.addEventListener('mousedown', handleWindowMouseDown)
53
+ listener(evt)
54
+ })
55
+
56
+ useEffect(() => {
57
+ document.addEventListener('mousedown', eventHandler)
58
+ document.addEventListener('touchstart', eventHandler)
92
59
 
93
60
  return () => {
94
- window.removeEventListener('mousedown', handleWindowMouseDown)
61
+ document.removeEventListener('mousedown', eventHandler)
62
+ document.removeEventListener('touchstart', eventHandler)
95
63
  }
96
- }, [boundaryElement, listener, elements])
97
-
98
- return setElement
64
+ }, [eventHandler])
99
65
  }
@@ -6,6 +6,7 @@ import {ElementSize, _elementSizeObserver} from '../observers/elementSizeObserve
6
6
  * @beta
7
7
  */
8
8
  export function useElementSize(element: HTMLElement | null): ElementSize | null {
9
+ // @TODO we can probably use something in framer-motion or @floating-ui instead of rolling our own
9
10
  const [size, setSize] = useState<ElementSize | null>(null)
10
11
 
11
12
  useEffect(() => {
@@ -0,0 +1,46 @@
1
+ import {useDebugValue, useMemo, useSyncExternalStore} from 'react'
2
+
3
+ /**
4
+ * Efficiently subscribes to `window.matchMedia` queries
5
+ *
6
+ * @param getServerSnapshot - Only called during server-side rendering, and hydration if using hydrateRoot. Required if the hook is called during SSR (https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering)
7
+ *
8
+ * @public
9
+ */
10
+ export function useMatchMedia(
11
+ mediaQueryString: `(${string})`,
12
+ getServerSnapshot?: () => boolean,
13
+ ): boolean {
14
+ const {subscribe, getSnapshot} = useMemo(() => {
15
+ /**
16
+ * `subscribe` and `getSnapshot` are only called on the client and both need access to the same `matchMedia` instance
17
+ * we don't want to eagerly instantiate it to ensure it's only created when actually used
18
+ */
19
+ let MEDIA_QUERY_CACHE: MediaQueryList | undefined
20
+
21
+ const getMatchMedia = (): MediaQueryList => {
22
+ if (!MEDIA_QUERY_CACHE) {
23
+ // As this function is only called during `subscribe` and `getSnapshot`, we can assume that the
24
+ // the `window` global is available and we're in a browser environment
25
+ MEDIA_QUERY_CACHE = window.matchMedia(mediaQueryString)
26
+ }
27
+
28
+ return MEDIA_QUERY_CACHE
29
+ }
30
+
31
+ return {
32
+ subscribe: (onStoreChange: () => void): (() => void) => {
33
+ const matchMedia = getMatchMedia()
34
+
35
+ matchMedia.addEventListener('change', onStoreChange)
36
+
37
+ return () => matchMedia.removeEventListener('change', onStoreChange)
38
+ },
39
+ getSnapshot: () => getMatchMedia().matches,
40
+ }
41
+ }, [mediaQueryString])
42
+
43
+ useDebugValue(mediaQueryString)
44
+
45
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
46
+ }
@@ -1,4 +1,4 @@
1
- import {useSyncExternalStore} from 'react'
1
+ import {useMemo, useSyncExternalStore} from 'react'
2
2
  import {useTheme_v2} from '../../theme'
3
3
 
4
4
  /**
@@ -9,8 +9,6 @@ export interface _MediaStore {
9
9
  getSnapshot: () => number
10
10
  }
11
11
 
12
- const MEDIA_STORE_CACHE = new WeakMap<number[], _MediaStore>()
13
-
14
12
  type MediaQueryMinWidth = `(min-width: ${number}px)`
15
13
  type MediaQueryMaxWidth = `(max-width: ${number}px)`
16
14
  type MediaQueryMinMaxWidth = `${MediaQueryMinWidth} and ${MediaQueryMaxWidth}`
@@ -97,13 +95,7 @@ function getServerSnapshot() {
97
95
  */
98
96
  export function useMediaIndex(): number {
99
97
  const {media} = useTheme_v2()
100
-
101
- let store = MEDIA_STORE_CACHE.get(media)
102
-
103
- if (!store) {
104
- store = _createMediaStore(media)
105
- MEDIA_STORE_CACHE.set(media, store)
106
- }
98
+ const store = useMemo(() => _createMediaStore(media), [media])
107
99
 
108
100
  return useSyncExternalStore(store.subscribe, store.getSnapshot, getServerSnapshot)
109
101
  }