@sanity/ui 0.36.16 → 0.37.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,11 +1,11 @@
1
- import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'
1
+ import React, {forwardRef, useCallback, useEffect, useMemo} from 'react'
2
2
  import styled from 'styled-components'
3
- import {useClickOutside, useGlobalKeyDown} from '../../hooks'
3
+ import {useClickOutside, useForwardedRef, useGlobalKeyDown} from '../../hooks'
4
4
  import {Box, Stack} from '../../primitives'
5
5
  import {ResponsivePaddingProps} from '../../primitives/types'
6
6
  import {useLayer} from '../../utils'
7
- import {_getFocusableElements, _sortElements} from './helpers'
8
7
  import {MenuContext, MenuContextValue} from './menuContext'
8
+ import {useMenuController} from './useMenuController'
9
9
 
10
10
  /**
11
11
  * @public
@@ -39,7 +39,7 @@ const Root = styled(Box)`
39
39
  */
40
40
  export const Menu = forwardRef(function Menu(
41
41
  props: MenuProps & Omit<React.HTMLProps<HTMLDivElement>, 'as' | 'height' | 'role' | 'tabIndex'>,
42
- ref
42
+ ref: React.ForwardedRef<HTMLDivElement>
43
43
  ) {
44
44
  const {
45
45
  children,
@@ -60,221 +60,35 @@ export const Menu = forwardRef(function Menu(
60
60
  ...restProps
61
61
  } = props
62
62
 
63
+ const forwardedRef = useForwardedRef(ref)
64
+
63
65
  const {isTopLayer} = useLayer()
64
- const [rootElement, setRootElement] = useState<HTMLDivElement | null>(null)
65
- const elementsRef = useRef<HTMLElement[]>([])
66
- const [activeIndex, setActiveIndex] = useState(-1)
67
- const activeIndexRef = useRef(activeIndex)
68
- const activeElement = elementsRef.current[activeIndex] || null
69
- const activeElementRef = useRef<HTMLElement | null>(activeElement)
70
- const mounted = Boolean(rootElement)
71
66
 
72
- const setRef = useCallback(
67
+ const {
68
+ activeElement,
69
+ activeIndex,
70
+ handleItemMouseEnter,
71
+ handleItemMouseLeave,
72
+ handleKeyDown,
73
+ mount,
74
+ rootElement,
75
+ setRootElement,
76
+ } = useMenuController({onKeyDown, originElement, shouldFocus})
77
+
78
+ const handleRefChange = useCallback(
73
79
  (el: HTMLDivElement | null) => {
74
80
  setRootElement(el)
75
- if (typeof ref === 'function') ref(el)
76
- else if (ref) ref.current = el
81
+ forwardedRef.current = el
77
82
  },
78
- [ref]
83
+ [forwardedRef, setRootElement]
79
84
  )
80
85
 
81
- const mount = useCallback(
82
- (element: HTMLElement | null, selected?: boolean): (() => void) => {
83
- if (!element) return () => undefined
84
-
85
- if (elementsRef.current.indexOf(element) === -1) {
86
- elementsRef.current.push(element)
87
- _sortElements(rootElement, elementsRef.current)
88
- }
89
-
90
- if (selected) {
91
- const selectedIndex = elementsRef.current.indexOf(element)
92
-
93
- setActiveIndex(selectedIndex)
94
- activeIndexRef.current = selectedIndex
95
- }
96
-
97
- return () => {
98
- const idx = elementsRef.current.indexOf(element)
99
-
100
- if (idx > -1) {
101
- elementsRef.current.splice(idx, 1)
102
- }
103
- }
104
- },
105
- [rootElement]
106
- )
107
-
108
- const handleKeyDown = useCallback(
109
- (event: React.KeyboardEvent<HTMLDivElement>) => {
110
- // Move focus to the element that opened the menu before handling the `Tab` press
111
- if (event.key === 'Tab') {
112
- if (originElement) {
113
- originElement.focus()
114
- }
115
-
116
- return
117
- }
118
-
119
- // Move focus to the first focusable menuitem
120
- if (event.key === 'Home') {
121
- event.preventDefault()
122
- event.stopPropagation()
123
-
124
- const focusableElements = _getFocusableElements(elementsRef.current)
125
- const el = focusableElements[0]
126
-
127
- if (!el) return
128
-
129
- const currentIndex = elementsRef.current.indexOf(el)
130
-
131
- setActiveIndex(currentIndex)
132
- activeIndexRef.current = currentIndex
133
-
134
- return
135
- }
136
-
137
- // Move focus to the last focusable menuitem
138
- if (event.key === 'End') {
139
- event.preventDefault()
140
- event.stopPropagation()
141
-
142
- const focusableElements = _getFocusableElements(elementsRef.current)
143
- const el = focusableElements[focusableElements.length - 1]
144
-
145
- if (!el) return
146
-
147
- const currentIndex = elementsRef.current.indexOf(el)
148
-
149
- setActiveIndex(currentIndex)
150
- activeIndexRef.current = currentIndex
151
-
152
- return
153
- }
154
-
155
- if (event.key === 'ArrowUp') {
156
- event.preventDefault()
157
- event.stopPropagation()
158
-
159
- const focusableElements = _getFocusableElements(elementsRef.current)
160
- const focusableLen = focusableElements.length
161
-
162
- if (focusableLen === 0) return
163
-
164
- const focusedElement = elementsRef.current[activeIndex]
165
-
166
- let focusedIndex = focusableElements.indexOf(focusedElement)
167
-
168
- focusedIndex = (focusedIndex - 1 + focusableLen) % focusableLen
169
-
170
- const el = focusableElements[focusedIndex]
171
- const currentIndex = elementsRef.current.indexOf(el)
172
-
173
- setActiveIndex(currentIndex)
174
- activeIndexRef.current = currentIndex
175
-
176
- return
177
- }
178
-
179
- if (event.key === 'ArrowDown') {
180
- event.preventDefault()
181
- event.stopPropagation()
182
-
183
- const focusableElements = _getFocusableElements(elementsRef.current)
184
- const focusableLen = focusableElements.length
185
-
186
- if (focusableLen === 0) return
187
-
188
- const focusedElement = elementsRef.current[activeIndex]
189
-
190
- let focusedIndex = focusableElements.indexOf(focusedElement)
191
-
192
- focusedIndex = (focusedIndex + 1) % focusableLen
193
-
194
- const el = focusableElements[focusedIndex]
195
- const currentIndex = elementsRef.current.indexOf(el)
196
-
197
- setActiveIndex(currentIndex)
198
- activeIndexRef.current = currentIndex
199
-
200
- return
201
- }
202
-
203
- if (onKeyDown) {
204
- onKeyDown(event)
205
- }
206
- },
207
- [activeIndex, onKeyDown, originElement]
208
- )
209
-
210
- const handleItemMouseEnter = useCallback((event: React.MouseEvent<HTMLElement>) => {
211
- const element = event.currentTarget
212
- const currentIndex = elementsRef.current.indexOf(element)
213
-
214
- setActiveIndex(currentIndex)
215
- activeIndexRef.current = currentIndex
216
- }, [])
217
-
218
- const handleItemMouseLeave = useCallback(() => {
219
- rootElement?.focus()
220
- setActiveIndex(-1)
221
- activeIndexRef.current = -1
222
- }, [rootElement])
223
-
224
86
  // Trigger `onItemSelect` when active index changes
225
87
  useEffect(() => {
226
88
  if (onItemSelect) onItemSelect(activeIndex)
227
89
  }, [activeIndex, onItemSelect])
228
90
 
229
- useEffect(() => {
230
- activeElementRef.current = activeElement
231
- }, [activeElement])
232
-
233
- // Set focus on the currently active element
234
- useEffect(() => {
235
- if (!mounted) return
236
-
237
- const rafId = window.requestAnimationFrame(() => {
238
- const _activeIndex = activeIndexRef.current
239
-
240
- if (_activeIndex === -1) {
241
- if (shouldFocus === 'first') {
242
- const focusableElements = _getFocusableElements(elementsRef.current)
243
- const el = focusableElements[0]
244
-
245
- if (el) {
246
- const currentIndex = elementsRef.current.indexOf(el)
247
-
248
- setActiveIndex(currentIndex)
249
- activeIndexRef.current = currentIndex
250
- }
251
- }
252
-
253
- if (shouldFocus === 'last') {
254
- const focusableElements = _getFocusableElements(elementsRef.current)
255
- const el = focusableElements[focusableElements.length - 1]
256
-
257
- if (el) {
258
- const currentIndex = elementsRef.current.indexOf(el)
259
-
260
- setActiveIndex(currentIndex)
261
- activeIndexRef.current = currentIndex
262
- }
263
- }
264
-
265
- return
266
- }
267
-
268
- const element = elementsRef.current[_activeIndex] || null
269
-
270
- element?.focus()
271
- })
272
-
273
- return () => {
274
- window.cancelAnimationFrame(rafId)
275
- }
276
- }, [activeIndex, mounted, shouldFocus])
277
-
91
+ // Close menu when clicking outside
278
92
  useClickOutside(
279
93
  useCallback(
280
94
  (event) => isTopLayer && onClickOutside && onClickOutside(event),
@@ -283,6 +97,7 @@ export const Menu = forwardRef(function Menu(
283
97
  [rootElement]
284
98
  )
285
99
 
100
+ // Close menu when pressing Escape
286
101
  useGlobalKeyDown(
287
102
  useCallback(
288
103
  (event) => {
@@ -297,6 +112,7 @@ export const Menu = forwardRef(function Menu(
297
112
  )
298
113
  )
299
114
 
115
+ // Register root element (for nested menus)
300
116
  useEffect(() => {
301
117
  if (!rootElement || !registerElement) return
302
118
 
@@ -311,10 +127,14 @@ export const Menu = forwardRef(function Menu(
311
127
  mount,
312
128
  onClickOutside,
313
129
  onEscape,
314
- onMouseEnter: handleItemMouseEnter,
315
- onMouseLeave: handleItemMouseLeave,
316
130
  onItemClick,
131
+ onItemMouseEnter: handleItemMouseEnter,
132
+ onItemMouseLeave: handleItemMouseLeave,
317
133
  registerElement,
134
+
135
+ // deprecated
136
+ onMouseEnter: handleItemMouseEnter,
137
+ onMouseLeave: handleItemMouseLeave,
318
138
  }),
319
139
  [
320
140
  activeElement,
@@ -336,7 +156,7 @@ export const Menu = forwardRef(function Menu(
336
156
  {...restProps}
337
157
  onKeyDown={handleKeyDown}
338
158
  padding={padding}
339
- ref={setRef}
159
+ ref={handleRefChange}
340
160
  role="menu"
341
161
  tabIndex={-1}
342
162
  >
@@ -9,9 +9,19 @@ export interface MenuContextValue {
9
9
  onClickOutside?: (event: MouseEvent) => void
10
10
  onEscape?: () => void
11
11
  onItemClick?: () => void
12
+ onItemMouseEnter?: (event: React.MouseEvent<HTMLElement>) => void
13
+ onItemMouseLeave?: (event: React.MouseEvent<HTMLElement>) => void
14
+ registerElement?: (el: HTMLElement) => () => void
15
+
16
+ /**
17
+ * @deprecated Use `onItemMouseEnter` instead
18
+ */
12
19
  onMouseEnter: (event: React.MouseEvent<HTMLElement>) => void
20
+
21
+ /**
22
+ * @deprecated Use `onItemMouseLeave` instead
23
+ */
13
24
  onMouseLeave: (event: React.MouseEvent<HTMLElement>) => void
14
- registerElement?: (el: HTMLElement) => () => void
15
25
  }
16
26
 
17
27
  const key = Symbol.for('@sanity/ui/context/menu')
@@ -1,7 +1,7 @@
1
1
  import {ChevronRightIcon} from '@sanity/icons'
2
- import React, {useCallback, useEffect, useRef, useState} from 'react'
3
- import styled from 'styled-components'
4
- import {Box, Popover, PopoverProps, Text} from '../../primitives'
2
+ import React, {createElement, isValidElement, useCallback, useEffect, useRef, useState} from 'react'
3
+ import {isValidElementType} from 'react-is'
4
+ import {Box, Flex, Popover, PopoverProps, Text} from '../../primitives'
5
5
  import {Selectable} from '../../primitives/_selectable'
6
6
  import {SelectableTone} from '../../types/selectable'
7
7
  import {Menu} from './menu'
@@ -13,26 +13,15 @@ import {useMenu} from './useMenu'
13
13
  export interface MenuGroupProps {
14
14
  as?: React.ElementType | keyof JSX.IntrinsicElements
15
15
  fontSize?: number | number[]
16
+ icon?: React.ComponentType | React.ReactNode
16
17
  padding?: number | number[]
17
18
  popover?: Omit<PopoverProps, 'content' | 'open'>
18
19
  radius?: number | number[]
20
+ space?: number | number[]
19
21
  text: React.ReactNode
20
22
  tone?: SelectableTone
21
23
  }
22
24
 
23
- const MOUSE_LEAVE_TIMEOUT = 1000
24
-
25
- const TextContainer = styled.span`
26
- &:not([hidden]) {
27
- display: flex;
28
- }
29
-
30
- & > div:first-child {
31
- flex: 1;
32
- min-width: 0;
33
- }
34
- `
35
-
36
25
  /**
37
26
  * @public
38
27
  */
@@ -44,56 +33,39 @@ export function MenuGroup(
44
33
  as = 'button',
45
34
  children,
46
35
  fontSize,
36
+ icon,
47
37
  onClick,
48
38
  padding = 3,
49
39
  popover = {},
50
40
  radius = 2,
41
+ space = 3,
51
42
  text,
52
43
  tone = 'default',
53
44
  ...restProps
54
45
  } = props
55
- const [open, setOpen] = useState(false)
46
+ const menu = useMenu()
56
47
  const {
57
48
  activeElement,
58
49
  mount,
59
50
  onClickOutside,
60
51
  onEscape,
61
52
  onItemClick,
62
- onMouseEnter,
63
- onMouseLeave,
53
+ onItemMouseEnter = menu.onMouseEnter,
64
54
  registerElement,
65
- } = useMenu()
55
+ } = menu
66
56
  const [rootElement, setRootElement] = useState<HTMLButtonElement | null>(null)
67
- const mouseLeaveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
57
+ const [open, setOpen] = useState(false)
68
58
  const shouldFocusRef = useRef<'first' | 'last' | null>(null)
69
59
  const active = Boolean(activeElement) && activeElement === rootElement
70
- // const selected = active || open
71
-
72
- // Register the element
73
- useEffect(() => mount(rootElement), [mount, rootElement])
60
+ const [withinMenu, setWithinMenu] = useState(false)
74
61
 
75
62
  const handleMouseEnter = useCallback(
76
63
  (event: React.MouseEvent<HTMLElement>) => {
77
- if (mouseLeaveTimeoutRef.current) {
78
- clearTimeout(mouseLeaveTimeoutRef.current)
79
- mouseLeaveTimeoutRef.current = null
80
- }
81
-
82
- onMouseEnter(event)
64
+ setWithinMenu(false)
65
+ onItemMouseEnter(event)
83
66
  setOpen(true)
84
67
  },
85
- [onMouseEnter]
86
- )
87
-
88
- const handleMouseLeave = useCallback(
89
- (event: React.MouseEvent<HTMLElement>) => {
90
- onMouseLeave(event)
91
-
92
- mouseLeaveTimeoutRef.current = setTimeout(() => {
93
- setOpen(false)
94
- }, MOUSE_LEAVE_TIMEOUT)
95
- },
96
- [onMouseLeave]
68
+ [onItemMouseEnter]
97
69
  )
98
70
 
99
71
  const handleMenuKeyDown = useCallback(
@@ -111,19 +83,6 @@ export function MenuGroup(
111
83
  [rootElement]
112
84
  )
113
85
 
114
- const handleMenuMouseEnter = useCallback(() => {
115
- if (mouseLeaveTimeoutRef.current) {
116
- clearTimeout(mouseLeaveTimeoutRef.current)
117
- mouseLeaveTimeoutRef.current = null
118
- }
119
- }, [])
120
-
121
- const handleMenuMouseLeave = useCallback(() => {
122
- mouseLeaveTimeoutRef.current = setTimeout(() => {
123
- setOpen(false)
124
- }, MOUSE_LEAVE_TIMEOUT)
125
- }, [])
126
-
127
86
  const handleClick = useCallback(
128
87
  (event: React.MouseEvent<HTMLDivElement>) => {
129
88
  if (onClick) onClick(event)
@@ -139,28 +98,33 @@ export function MenuGroup(
139
98
  [onClick]
140
99
  )
141
100
 
142
- const handleItemClick = useCallback(() => {
101
+ const handleChildItemClick = useCallback(() => {
143
102
  setOpen(false)
144
103
  if (onItemClick) onItemClick()
145
104
  }, [onItemClick])
146
105
 
106
+ const handleMenuMouseEnter = useCallback(() => setWithinMenu(true), [])
107
+
108
+ // Register the menu item element
109
+ useEffect(() => mount(rootElement), [mount, rootElement])
110
+
111
+ // Close child menu when a sibling item becomes active
147
112
  useEffect(() => {
148
- return () => {
149
- if (mouseLeaveTimeoutRef.current !== null) {
150
- clearTimeout(mouseLeaveTimeoutRef.current)
151
- mouseLeaveTimeoutRef.current = null
152
- }
153
- }
154
- }, [])
113
+ if (!active) setOpen(false)
114
+ }, [active])
115
+
116
+ // Update state when child menu is no longer open
117
+ useEffect(() => {
118
+ if (!open) setWithinMenu(false)
119
+ }, [open])
155
120
 
156
- const content = (
121
+ const childMenu = (
157
122
  <Menu
158
123
  onClickOutside={onClickOutside}
159
124
  onEscape={onEscape}
160
- onItemClick={handleItemClick}
125
+ onItemClick={handleChildItemClick}
161
126
  onKeyDown={handleMenuKeyDown}
162
127
  onMouseEnter={handleMenuMouseEnter}
163
- onMouseLeave={handleMenuMouseLeave}
164
128
  registerElement={registerElement}
165
129
  shouldFocus={shouldFocusRef.current}
166
130
  >
@@ -179,6 +143,7 @@ export function MenuGroup(
179
143
  shouldFocusRef.current = 'first'
180
144
 
181
145
  setOpen(true)
146
+ setWithinMenu(true)
182
147
 
183
148
  requestAnimationFrame(() => {
184
149
  shouldFocusRef.current = null
@@ -189,32 +154,45 @@ export function MenuGroup(
189
154
  }, [])
190
155
 
191
156
  return (
192
- <Popover {...popover} content={content} data-ui="MenuGroup__popover" open={open}>
157
+ <Popover {...popover} content={childMenu} data-ui="MenuGroup__popover" open={open}>
193
158
  <Selectable
194
159
  data-as={as}
195
160
  data-ui="MenuGroup"
196
161
  forwardedAs={as}
197
162
  {...restProps}
198
- aria-pressed={as === 'button' ? !active && open : undefined}
199
- data-pressed={as !== 'button' ? !active && open : undefined}
200
- data-selected={active ? '' : undefined}
163
+ aria-pressed={as === 'button' ? withinMenu : undefined}
164
+ data-pressed={as !== 'button' ? withinMenu : undefined}
165
+ data-selected={!withinMenu && active ? '' : undefined}
201
166
  $radius={radius}
202
167
  $tone={tone}
203
168
  onClick={handleClick}
204
169
  onKeyDown={handleKeyDown}
205
170
  onMouseEnter={handleMouseEnter}
206
- onMouseLeave={handleMouseLeave}
207
171
  ref={setRootElement}
208
172
  tabIndex={-1}
209
173
  type={as === 'button' ? 'button' : undefined}
210
174
  >
211
175
  <Box padding={padding}>
212
- <TextContainer>
213
- <Text size={fontSize}>{text}</Text>
214
- <Text size={fontSize}>
215
- <ChevronRightIcon />
216
- </Text>
217
- </TextContainer>
176
+ <Flex>
177
+ {icon && (
178
+ <Text size={fontSize}>
179
+ {isValidElement(icon) && icon}
180
+ {isValidElementType(icon) && createElement(icon)}
181
+ </Text>
182
+ )}
183
+
184
+ <Box flex={1} marginLeft={icon ? space : undefined}>
185
+ <Text size={fontSize} textOverflow="ellipsis">
186
+ {text}
187
+ </Text>
188
+ </Box>
189
+
190
+ <Box marginLeft={space}>
191
+ <Text size={fontSize}>
192
+ <ChevronRightIcon />
193
+ </Text>
194
+ </Box>
195
+ </Flex>
218
196
  </Box>
219
197
  </Selectable>
220
198
  </Popover>
@@ -64,9 +64,16 @@ export const MenuItem = forwardRef(function MenuItem(
64
64
  tone = 'default',
65
65
  ...restProps
66
66
  } = props
67
- const {activeElement, mount, onItemClick, onMouseEnter, onMouseLeave} = useMenu()
67
+ const menu = useMenu()
68
+ const {
69
+ activeElement,
70
+ mount,
71
+ onItemClick,
72
+ onItemMouseEnter = menu.onMouseEnter,
73
+ onItemMouseLeave = menu.onMouseLeave,
74
+ } = menu
68
75
  const [rootElement, setRootElement] = useState<HTMLDivElement | null>(null)
69
- const selected = Boolean(activeElement) && rootElement === activeElement
76
+ const active = Boolean(activeElement) && activeElement === rootElement
70
77
 
71
78
  useEffect(() => mount(rootElement, selectedProp), [mount, rootElement, selectedProp])
72
79
 
@@ -110,14 +117,14 @@ export const MenuItem = forwardRef(function MenuItem(
110
117
  {...restProps}
111
118
  aria-pressed={as === 'button' && pressed}
112
119
  data-pressed={as !== 'button' && pressed ? '' : undefined}
113
- data-selected={selected ? '' : undefined}
120
+ data-selected={active ? '' : undefined}
114
121
  data-disabled={disabled ? '' : undefined}
115
122
  $radius={radius}
116
123
  $tone={tone}
117
124
  disabled={disabled}
118
125
  onClick={handleClick}
119
- onMouseEnter={onMouseEnter}
120
- onMouseLeave={onMouseLeave}
126
+ onMouseEnter={onItemMouseEnter}
127
+ onMouseLeave={onItemMouseLeave}
121
128
  padding={0}
122
129
  ref={setRef}
123
130
  role="menuitem"