@sanity/ui 0.36.17 → 0.37.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.
@@ -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,38 @@ 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)
56
46
  const {
57
47
  activeElement,
58
48
  mount,
59
49
  onClickOutside,
60
50
  onEscape,
61
51
  onItemClick,
62
- onMouseEnter,
63
- onMouseLeave,
52
+ onItemMouseEnter,
64
53
  registerElement,
65
54
  } = useMenu()
66
55
  const [rootElement, setRootElement] = useState<HTMLButtonElement | null>(null)
67
- const mouseLeaveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
56
+ const [open, setOpen] = useState(false)
68
57
  const shouldFocusRef = useRef<'first' | 'last' | null>(null)
69
58
  const active = Boolean(activeElement) && activeElement === rootElement
70
- // const selected = active || open
71
-
72
- // Register the element
73
- useEffect(() => mount(rootElement), [mount, rootElement])
59
+ const [withinMenu, setWithinMenu] = useState(false)
74
60
 
75
61
  const handleMouseEnter = useCallback(
76
62
  (event: React.MouseEvent<HTMLElement>) => {
77
- if (mouseLeaveTimeoutRef.current) {
78
- clearTimeout(mouseLeaveTimeoutRef.current)
79
- mouseLeaveTimeoutRef.current = null
80
- }
81
-
82
- onMouseEnter(event)
63
+ setWithinMenu(false)
64
+ onItemMouseEnter(event)
83
65
  setOpen(true)
84
66
  },
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]
67
+ [onItemMouseEnter]
97
68
  )
98
69
 
99
70
  const handleMenuKeyDown = useCallback(
@@ -111,19 +82,6 @@ export function MenuGroup(
111
82
  [rootElement]
112
83
  )
113
84
 
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
85
  const handleClick = useCallback(
128
86
  (event: React.MouseEvent<HTMLDivElement>) => {
129
87
  if (onClick) onClick(event)
@@ -139,28 +97,33 @@ export function MenuGroup(
139
97
  [onClick]
140
98
  )
141
99
 
142
- const handleItemClick = useCallback(() => {
100
+ const handleChildItemClick = useCallback(() => {
143
101
  setOpen(false)
144
102
  if (onItemClick) onItemClick()
145
103
  }, [onItemClick])
146
104
 
105
+ const handleMenuMouseEnter = useCallback(() => setWithinMenu(true), [])
106
+
107
+ // Register the menu item element
108
+ useEffect(() => mount(rootElement), [mount, rootElement])
109
+
110
+ // Close child menu when a sibling item becomes active
147
111
  useEffect(() => {
148
- return () => {
149
- if (mouseLeaveTimeoutRef.current !== null) {
150
- clearTimeout(mouseLeaveTimeoutRef.current)
151
- mouseLeaveTimeoutRef.current = null
152
- }
153
- }
154
- }, [])
112
+ if (!active) setOpen(false)
113
+ }, [active])
114
+
115
+ // Update state when child menu is no longer open
116
+ useEffect(() => {
117
+ if (!open) setWithinMenu(false)
118
+ }, [open])
155
119
 
156
- const content = (
120
+ const childMenu = (
157
121
  <Menu
158
122
  onClickOutside={onClickOutside}
159
123
  onEscape={onEscape}
160
- onItemClick={handleItemClick}
124
+ onItemClick={handleChildItemClick}
161
125
  onKeyDown={handleMenuKeyDown}
162
126
  onMouseEnter={handleMenuMouseEnter}
163
- onMouseLeave={handleMenuMouseLeave}
164
127
  registerElement={registerElement}
165
128
  shouldFocus={shouldFocusRef.current}
166
129
  >
@@ -179,6 +142,7 @@ export function MenuGroup(
179
142
  shouldFocusRef.current = 'first'
180
143
 
181
144
  setOpen(true)
145
+ setWithinMenu(true)
182
146
 
183
147
  requestAnimationFrame(() => {
184
148
  shouldFocusRef.current = null
@@ -189,32 +153,45 @@ export function MenuGroup(
189
153
  }, [])
190
154
 
191
155
  return (
192
- <Popover {...popover} content={content} data-ui="MenuGroup__popover" open={open}>
156
+ <Popover {...popover} content={childMenu} data-ui="MenuGroup__popover" open={open}>
193
157
  <Selectable
194
158
  data-as={as}
195
159
  data-ui="MenuGroup"
196
160
  forwardedAs={as}
197
161
  {...restProps}
198
- aria-pressed={as === 'button' ? !active && open : undefined}
199
- data-pressed={as !== 'button' ? !active && open : undefined}
200
- data-selected={active ? '' : undefined}
162
+ aria-pressed={as === 'button' ? withinMenu : undefined}
163
+ data-pressed={as !== 'button' ? withinMenu : undefined}
164
+ data-selected={!withinMenu && active ? '' : undefined}
201
165
  $radius={radius}
202
166
  $tone={tone}
203
167
  onClick={handleClick}
204
168
  onKeyDown={handleKeyDown}
205
169
  onMouseEnter={handleMouseEnter}
206
- onMouseLeave={handleMouseLeave}
207
170
  ref={setRootElement}
208
171
  tabIndex={-1}
209
172
  type={as === 'button' ? 'button' : undefined}
210
173
  >
211
174
  <Box padding={padding}>
212
- <TextContainer>
213
- <Text size={fontSize}>{text}</Text>
214
- <Text size={fontSize}>
215
- <ChevronRightIcon />
216
- </Text>
217
- </TextContainer>
175
+ <Flex>
176
+ {icon && (
177
+ <Text size={fontSize}>
178
+ {isValidElement(icon) && icon}
179
+ {isValidElementType(icon) && createElement(icon)}
180
+ </Text>
181
+ )}
182
+
183
+ <Box flex={1} marginLeft={icon ? space : undefined}>
184
+ <Text size={fontSize} textOverflow="ellipsis">
185
+ {text}
186
+ </Text>
187
+ </Box>
188
+
189
+ <Box marginLeft={space}>
190
+ <Text size={fontSize}>
191
+ <ChevronRightIcon />
192
+ </Text>
193
+ </Box>
194
+ </Flex>
218
195
  </Box>
219
196
  </Selectable>
220
197
  </Popover>
@@ -64,9 +64,9 @@ 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 {activeElement, mount, onItemClick, onItemMouseEnter, onItemMouseLeave} = useMenu()
68
68
  const [rootElement, setRootElement] = useState<HTMLDivElement | null>(null)
69
- const selected = Boolean(activeElement) && rootElement === activeElement
69
+ const active = Boolean(activeElement) && activeElement === rootElement
70
70
 
71
71
  useEffect(() => mount(rootElement, selectedProp), [mount, rootElement, selectedProp])
72
72
 
@@ -110,14 +110,14 @@ export const MenuItem = forwardRef(function MenuItem(
110
110
  {...restProps}
111
111
  aria-pressed={as === 'button' && pressed}
112
112
  data-pressed={as !== 'button' && pressed ? '' : undefined}
113
- data-selected={selected ? '' : undefined}
113
+ data-selected={active ? '' : undefined}
114
114
  data-disabled={disabled ? '' : undefined}
115
115
  $radius={radius}
116
116
  $tone={tone}
117
117
  disabled={disabled}
118
118
  onClick={handleClick}
119
- onMouseEnter={onMouseEnter}
120
- onMouseLeave={onMouseLeave}
119
+ onMouseEnter={onItemMouseEnter}
120
+ onMouseLeave={onItemMouseLeave}
121
121
  padding={0}
122
122
  ref={setRef}
123
123
  role="menuitem"
@@ -0,0 +1,239 @@
1
+ import {useCallback, useEffect, useRef, useState} from 'react'
2
+ import {_getFocusableElements, _sortElements} from './helpers'
3
+
4
+ /**
5
+ * @internal
6
+ */
7
+ export interface MenuController {
8
+ activeElement: HTMLElement | null
9
+ activeIndex: number
10
+ handleItemMouseEnter: (event: React.MouseEvent<HTMLElement>) => void
11
+ handleItemMouseLeave: () => void
12
+ handleKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
13
+ mount: (element: HTMLElement | null, selected?: boolean) => () => void
14
+ rootElement: HTMLDivElement | null
15
+ setRootElement: (el: HTMLDivElement | null) => void
16
+ }
17
+
18
+ /**
19
+ * This controller is responsible for controlling UI menu state.
20
+ *
21
+ * @internal
22
+ */
23
+ export function useMenuController(props: {
24
+ onKeyDown?: React.KeyboardEventHandler
25
+ originElement?: HTMLElement | null
26
+ shouldFocus: 'first' | 'last' | null
27
+ }): MenuController {
28
+ const {onKeyDown, originElement, shouldFocus} = props
29
+
30
+ const elementsRef = useRef<HTMLElement[]>([])
31
+
32
+ const [rootElement, setRootElement] = useState<HTMLDivElement | null>(null)
33
+ const [activeIndex, _setActiveIndex] = useState(-1)
34
+
35
+ const activeElement = elementsRef.current[activeIndex] || null
36
+ const activeIndexRef = useRef(activeIndex)
37
+
38
+ const mounted = Boolean(rootElement)
39
+
40
+ const setActiveIndex = useCallback((nextActiveIndex) => {
41
+ _setActiveIndex(nextActiveIndex)
42
+ activeIndexRef.current = nextActiveIndex
43
+ }, [])
44
+
45
+ const mount = useCallback(
46
+ (element: HTMLElement | null, selected?: boolean): (() => void) => {
47
+ if (!element) return () => undefined
48
+
49
+ if (elementsRef.current.indexOf(element) === -1) {
50
+ elementsRef.current.push(element)
51
+ _sortElements(rootElement, elementsRef.current)
52
+ }
53
+
54
+ if (selected) {
55
+ const selectedIndex = elementsRef.current.indexOf(element)
56
+
57
+ setActiveIndex(selectedIndex)
58
+ }
59
+
60
+ return () => {
61
+ const idx = elementsRef.current.indexOf(element)
62
+
63
+ if (idx > -1) {
64
+ elementsRef.current.splice(idx, 1)
65
+ }
66
+ }
67
+ },
68
+ [rootElement, setActiveIndex]
69
+ )
70
+
71
+ const handleKeyDown = useCallback(
72
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
73
+ // Move focus to the element that opened the menu before handling the `Tab` press
74
+ if (event.key === 'Tab') {
75
+ if (originElement) {
76
+ originElement.focus()
77
+ }
78
+
79
+ return
80
+ }
81
+
82
+ // Move focus to the first focusable menuitem
83
+ if (event.key === 'Home') {
84
+ event.preventDefault()
85
+ event.stopPropagation()
86
+
87
+ const focusableElements = _getFocusableElements(elementsRef.current)
88
+ const el = focusableElements[0]
89
+
90
+ if (!el) return
91
+
92
+ const currentIndex = elementsRef.current.indexOf(el)
93
+
94
+ setActiveIndex(currentIndex)
95
+
96
+ return
97
+ }
98
+
99
+ // Move focus to the last focusable menuitem
100
+ if (event.key === 'End') {
101
+ event.preventDefault()
102
+ event.stopPropagation()
103
+
104
+ const focusableElements = _getFocusableElements(elementsRef.current)
105
+ const el = focusableElements[focusableElements.length - 1]
106
+
107
+ if (!el) return
108
+
109
+ const currentIndex = elementsRef.current.indexOf(el)
110
+
111
+ setActiveIndex(currentIndex)
112
+
113
+ return
114
+ }
115
+
116
+ if (event.key === 'ArrowUp') {
117
+ event.preventDefault()
118
+ event.stopPropagation()
119
+
120
+ const focusableElements = _getFocusableElements(elementsRef.current)
121
+ const focusableLen = focusableElements.length
122
+
123
+ if (focusableLen === 0) return
124
+
125
+ const focusedElement = elementsRef.current[activeIndex]
126
+
127
+ let focusedIndex = focusableElements.indexOf(focusedElement)
128
+
129
+ focusedIndex = (focusedIndex - 1 + focusableLen) % focusableLen
130
+
131
+ const el = focusableElements[focusedIndex]
132
+ const currentIndex = elementsRef.current.indexOf(el)
133
+
134
+ setActiveIndex(currentIndex)
135
+
136
+ return
137
+ }
138
+
139
+ if (event.key === 'ArrowDown') {
140
+ event.preventDefault()
141
+ event.stopPropagation()
142
+
143
+ const focusableElements = _getFocusableElements(elementsRef.current)
144
+ const focusableLen = focusableElements.length
145
+
146
+ if (focusableLen === 0) return
147
+
148
+ const focusedElement = elementsRef.current[activeIndex]
149
+
150
+ let focusedIndex = focusableElements.indexOf(focusedElement)
151
+
152
+ focusedIndex = (focusedIndex + 1) % focusableLen
153
+
154
+ const el = focusableElements[focusedIndex]
155
+ const currentIndex = elementsRef.current.indexOf(el)
156
+
157
+ setActiveIndex(currentIndex)
158
+
159
+ return
160
+ }
161
+
162
+ if (onKeyDown) {
163
+ onKeyDown(event)
164
+ }
165
+ },
166
+ [activeIndex, onKeyDown, originElement, setActiveIndex]
167
+ )
168
+
169
+ const handleItemMouseEnter = useCallback(
170
+ (event: React.MouseEvent<HTMLElement>) => {
171
+ const element = event.currentTarget
172
+ const currentIndex = elementsRef.current.indexOf(element)
173
+
174
+ setActiveIndex(currentIndex)
175
+ },
176
+ [setActiveIndex]
177
+ )
178
+
179
+ const handleItemMouseLeave = useCallback(() => {
180
+ rootElement?.focus()
181
+ setActiveIndex(-1)
182
+ }, [rootElement, setActiveIndex])
183
+
184
+ // Set focus on the currently active element
185
+ useEffect(() => {
186
+ if (!mounted) return
187
+
188
+ const rafId = window.requestAnimationFrame(() => {
189
+ const _activeIndex = activeIndexRef.current
190
+
191
+ if (_activeIndex === -1) {
192
+ if (shouldFocus === 'first') {
193
+ const focusableElements = _getFocusableElements(elementsRef.current)
194
+ const el = focusableElements[0]
195
+
196
+ if (el) {
197
+ const currentIndex = elementsRef.current.indexOf(el)
198
+
199
+ setActiveIndex(currentIndex)
200
+ activeIndexRef.current = currentIndex
201
+ }
202
+ }
203
+
204
+ if (shouldFocus === 'last') {
205
+ const focusableElements = _getFocusableElements(elementsRef.current)
206
+ const el = focusableElements[focusableElements.length - 1]
207
+
208
+ if (el) {
209
+ const currentIndex = elementsRef.current.indexOf(el)
210
+
211
+ setActiveIndex(currentIndex)
212
+ activeIndexRef.current = currentIndex
213
+ }
214
+ }
215
+
216
+ return
217
+ }
218
+
219
+ const element = elementsRef.current[_activeIndex] || null
220
+
221
+ element?.focus()
222
+ })
223
+
224
+ return () => {
225
+ window.cancelAnimationFrame(rafId)
226
+ }
227
+ }, [activeIndex, mounted, setActiveIndex, shouldFocus])
228
+
229
+ return {
230
+ activeElement,
231
+ activeIndex,
232
+ handleItemMouseEnter,
233
+ handleItemMouseLeave,
234
+ handleKeyDown,
235
+ mount,
236
+ rootElement,
237
+ setRootElement,
238
+ }
239
+ }