@sanity/ui 3.0.10 → 3.0.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/ui",
3
- "version": "3.0.10",
3
+ "version": "3.0.11",
4
4
  "keywords": [
5
5
  "sanity",
6
6
  "ui",
@@ -156,7 +156,7 @@
156
156
  "@testing-library/user-event": "^14.6.1",
157
157
  "@types/jest": "^30.0.0",
158
158
  "@types/jest-axe": "^3.5.9",
159
- "@types/node": "^22.18.0",
159
+ "@types/node": "^22.18.1",
160
160
  "@types/react": "^19.1.11",
161
161
  "@types/react-dom": "^19.1.8",
162
162
  "@types/react-is": "^19.0.0",
@@ -16,6 +16,7 @@ import {
16
16
  useMemo,
17
17
  useReducer,
18
18
  useRef,
19
+ useState,
19
20
  } from 'react'
20
21
 
21
22
  import {EMPTY_ARRAY, EMPTY_RECORD} from '../../constants'
@@ -189,6 +190,8 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete<
189
190
  const resultsPopoverElementRef = useRef<HTMLDivElement | null>(null)
190
191
  const inputElementRef = useRef<HTMLInputElement | null>(null)
191
192
  const listBoxElementRef = useRef<HTMLDivElement | null>(null)
193
+ // Element refs that need to be accessed during render
194
+ const [inputElement, setInputElement] = useState<HTMLInputElement | null>(null)
192
195
 
193
196
  // Value refs
194
197
  const listFocusedRef = useRef(false)
@@ -196,10 +199,17 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete<
196
199
  const valuePropRef = useRef(valueProp)
197
200
  const popoverMouseWithinRef = useRef(false)
198
201
 
199
- // Forward ref to parent
202
+ // Forward inputElement state to inputElementRef
203
+ useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(
204
+ inputElementRef,
205
+ () => inputElement,
206
+ [inputElement],
207
+ )
208
+ // Forward inputElement to parent
200
209
  useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(
201
210
  forwardedRef,
202
- () => inputElementRef.current,
211
+ () => inputElement,
212
+ [inputElement],
203
213
  )
204
214
 
205
215
  const listBoxId = `${id}-listbox`
@@ -529,40 +539,6 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete<
529
539
  return query
530
540
  }, [currentOption, query, renderValue, value])
531
541
 
532
- const input = (
533
- <TextInput
534
- {...restProps}
535
- aria-activedescendant={activeItemId}
536
- aria-autocomplete="list"
537
- aria-expanded={expanded}
538
- aria-owns={listBoxId}
539
- autoCapitalize="off"
540
- autoComplete="off"
541
- autoCorrect="off"
542
- border={border}
543
- clearButton={clearButton}
544
- customValidity={customValidity}
545
- disabled={disabled}
546
- fontSize={fontSize}
547
- icon={icon}
548
- iconRight={loading && AnimatedSpinnerIcon}
549
- id={id}
550
- inputMode="search"
551
- onChange={handleInputChange}
552
- onClear={handleClearButtonClick}
553
- onFocus={handleInputFocus}
554
- padding={padding}
555
- prefix={prefix}
556
- radius={radius}
557
- readOnly={readOnly}
558
- ref={inputElementRef}
559
- role="combobox"
560
- spellCheck={false}
561
- suffix={suffix || openButtonNode}
562
- value={inputValue}
563
- />
564
- )
565
-
566
542
  const handleListBoxKeyDown = useCallback(
567
543
  (event: KeyboardEvent<HTMLDivElement>) => {
568
544
  // If the focus is currently in the list, move focus to the input element
@@ -632,15 +608,16 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete<
632
608
 
633
609
  const results = useMemo(() => {
634
610
  if (renderPopover) {
635
- return renderPopover(
636
- {
637
- content,
638
- hidden: !expanded,
639
- inputElement: inputElementRef.current,
640
- onMouseEnter: handlePopoverMouseEnter,
641
- onMouseLeave: handlePopoverMouseLeave,
642
- },
643
- resultsPopoverElementRef,
611
+ return (
612
+ <RenderPopover
613
+ content={content}
614
+ hidden={!expanded}
615
+ inputElement={inputElement}
616
+ onMouseEnter={handlePopoverMouseEnter}
617
+ onMouseLeave={handlePopoverMouseLeave}
618
+ resultsPopoverElementRef={resultsPopoverElementRef}
619
+ renderPopover={renderPopover}
620
+ />
644
621
  )
645
622
  }
646
623
 
@@ -663,7 +640,7 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete<
663
640
  portal
664
641
  radius={radius}
665
642
  ref={resultsPopoverElementRef}
666
- referenceElement={inputElementRef.current}
643
+ referenceElement={inputElement}
667
644
  {...popover}
668
645
  />
669
646
  )
@@ -673,6 +650,7 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete<
673
650
  filteredOptionsLen,
674
651
  handlePopoverMouseEnter,
675
652
  handlePopoverMouseLeave,
653
+ inputElement,
676
654
  popover,
677
655
  radius,
678
656
  renderPopover,
@@ -686,12 +664,66 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete<
686
664
  onKeyDown={handleRootKeyDown}
687
665
  ref={rootElementRef}
688
666
  >
689
- {input}
667
+ <TextInput
668
+ {...restProps}
669
+ aria-activedescendant={activeItemId}
670
+ aria-autocomplete="list"
671
+ aria-expanded={expanded}
672
+ aria-owns={listBoxId}
673
+ autoCapitalize="off"
674
+ autoComplete="off"
675
+ autoCorrect="off"
676
+ border={border}
677
+ clearButton={clearButton}
678
+ customValidity={customValidity}
679
+ disabled={disabled}
680
+ fontSize={fontSize}
681
+ icon={icon}
682
+ iconRight={loading && AnimatedSpinnerIcon}
683
+ id={id}
684
+ inputMode="search"
685
+ onChange={handleInputChange}
686
+ onClear={handleClearButtonClick}
687
+ onFocus={handleInputFocus}
688
+ padding={padding}
689
+ prefix={prefix}
690
+ radius={radius}
691
+ readOnly={readOnly}
692
+ ref={setInputElement}
693
+ role="combobox"
694
+ spellCheck={false}
695
+ suffix={suffix || openButtonNode}
696
+ value={inputValue}
697
+ />
690
698
  {results}
691
699
  </StyledAutocomplete>
692
700
  )
693
701
  })
694
702
 
703
+ function RenderPopover({
704
+ renderPopover,
705
+ content,
706
+ hidden,
707
+ inputElement,
708
+ onMouseEnter,
709
+ onMouseLeave,
710
+ resultsPopoverElementRef,
711
+ }: {
712
+ renderPopover: Exclude<AutocompleteProps['renderPopover'], undefined>
713
+ resultsPopoverElementRef: Parameters<Exclude<AutocompleteProps['renderPopover'], undefined>>[1]
714
+ } & Parameters<Exclude<AutocompleteProps['renderPopover'], undefined>>[0]) {
715
+ return renderPopover(
716
+ {
717
+ content,
718
+ hidden,
719
+ inputElement,
720
+ onMouseEnter,
721
+ onMouseLeave,
722
+ },
723
+ resultsPopoverElementRef,
724
+ )
725
+ }
726
+
695
727
  InnerAutocomplete.displayName = 'ForwardRef(Autocomplete)'
696
728
 
697
729
  /**
@@ -32,18 +32,16 @@ describe('components/menu', () => {
32
32
  [],
33
33
  )
34
34
 
35
- const value: MenuContextValue = useMemo(
36
- () => ({
37
- version: 0.0,
38
- activeElement: null,
39
- activeIndex: 0,
40
- mount: (element: HTMLElement | null) => () => console.log(element),
41
- onItemClick: () => undefined,
42
- onItemMouseEnter: handleItemMouseEnter,
43
- onItemMouseLeave: handleItemMouseLeave,
44
- onMouseEnter: handleItemMouseEnter,
45
- onMouseLeave: handleItemMouseLeave,
46
- }),
35
+ const value = useMemo(
36
+ () =>
37
+ ({
38
+ version: 2,
39
+ activeElement: null,
40
+ mount: (element: HTMLElement | null) => () => console.log(element),
41
+ onItemClick: () => undefined,
42
+ onItemMouseEnter: handleItemMouseEnter,
43
+ onItemMouseLeave: handleItemMouseLeave,
44
+ }) satisfies MenuContextValue,
47
45
  [handleItemMouseEnter, handleItemMouseLeave],
48
46
  )
49
47
 
@@ -58,8 +56,7 @@ describe('components/menu', () => {
58
56
 
59
57
  const contextValue = log.mock.calls[0][0]
60
58
 
61
- expect(contextValue.version).toBe(0.0)
62
- expect(contextValue.activeIndex).toBe(0)
59
+ expect(contextValue.version).toBe(2)
63
60
  expect(typeof contextValue.mount).toBe('function')
64
61
  expect(typeof contextValue.onItemClick).toBe('function')
65
62
  expect(typeof contextValue.onItemMouseEnter).toBe('function')
@@ -128,26 +128,21 @@ export const Menu = forwardRef(function Menu(
128
128
  ),
129
129
  )
130
130
 
131
- const value: MenuContextValue = useMemo(
132
- () => ({
133
- version: 0.0,
134
- activeElement,
135
- activeIndex,
136
- mount,
137
- onClickOutside,
138
- onEscape,
139
- onItemClick,
140
- onItemMouseEnter: handleItemMouseEnter,
141
- onItemMouseLeave: handleItemMouseLeave,
142
- registerElement,
143
-
144
- // deprecated
145
- onMouseEnter: handleItemMouseEnter,
146
- onMouseLeave: handleItemMouseLeave,
147
- }),
131
+ const value = useMemo(
132
+ () =>
133
+ ({
134
+ version: 2,
135
+ activeElement,
136
+ mount,
137
+ onClickOutside,
138
+ onEscape,
139
+ onItemClick,
140
+ onItemMouseEnter: handleItemMouseEnter,
141
+ onItemMouseLeave: handleItemMouseLeave,
142
+ registerElement,
143
+ }) satisfies MenuContextValue,
148
144
  [
149
145
  activeElement,
150
- activeIndex,
151
146
  mount,
152
147
  handleItemMouseEnter,
153
148
  handleItemMouseLeave,
@@ -1,26 +1,15 @@
1
1
  import {createGlobalScopedContext} from '../../lib/createGlobalScopedContext'
2
2
 
3
3
  export interface MenuContextValue {
4
- version: 0.0
4
+ version: 2
5
5
  activeElement: HTMLElement | null
6
- activeIndex: number
7
6
  mount: (element: HTMLElement | null, selected?: boolean) => () => void
8
7
  onClickOutside?: (event: MouseEvent) => void
9
8
  onEscape?: () => void
10
9
  onItemClick?: () => void
11
- onItemMouseEnter?: (event: React.MouseEvent<HTMLElement>) => void
12
- onItemMouseLeave?: (event: React.MouseEvent<HTMLElement>) => void
10
+ onItemMouseEnter: (event: React.MouseEvent<HTMLElement>) => void
11
+ onItemMouseLeave: (event: React.MouseEvent<HTMLElement>) => void
13
12
  registerElement?: (el: HTMLElement) => () => void
14
-
15
- /**
16
- * @deprecated Use `onItemMouseEnter` instead
17
- */
18
- onMouseEnter: (event: React.MouseEvent<HTMLElement>) => void
19
-
20
- /**
21
- * @deprecated Use `onItemMouseLeave` instead
22
- */
23
- onMouseLeave: (event: React.MouseEvent<HTMLElement>) => void
24
13
  }
25
14
 
26
15
  export const MenuContext = createGlobalScopedContext<MenuContextValue | null>(
@@ -69,7 +69,7 @@ export function MenuGroup(
69
69
  onItemMouseEnter: _onItemMouseEnter,
70
70
  registerElement,
71
71
  } = menu
72
- const onItemMouseEnter = _onItemMouseEnter ?? menu.onMouseEnter
72
+ const onItemMouseEnter = _onItemMouseEnter ?? menu.onItemMouseEnter
73
73
  const [rootElement, setRootElement] = useState<HTMLButtonElement | HTMLDivElement | null>(null)
74
74
  const [open, setOpen] = useState(false)
75
75
  const [shouldFocus, setShouldFocus] = useState<'first' | 'last' | null>(null)
@@ -76,8 +76,8 @@ export const MenuItem = forwardRef(function MenuItem(
76
76
  onItemMouseEnter: _onItemMouseEnter,
77
77
  onItemMouseLeave: _onItemMouseLeave,
78
78
  } = menu
79
- const onItemMouseEnter = _onItemMouseEnter ?? menu.onMouseEnter
80
- const onItemMouseLeave = _onItemMouseLeave ?? menu.onMouseLeave
79
+ const onItemMouseEnter = _onItemMouseEnter ?? menu.onItemMouseEnter
80
+ const onItemMouseLeave = _onItemMouseLeave ?? menu.onItemMouseLeave
81
81
  const [rootElement, setRootElement] = useState<HTMLDivElement | null>(null)
82
82
  const active = Boolean(activeElement) && activeElement === rootElement
83
83
  const ref = useRef<HTMLDivElement | null>(null)
@@ -12,8 +12,8 @@ export function useMenu(): MenuContextValue {
12
12
 
13
13
  // NOTE: This check is for future-compatiblity
14
14
  // - If the value is not an object, it’s not compatible with the current version
15
- // - If the value is an object, but doesn’t have `version: 0.0`, it’s not compatible with the current version
16
- if (!isRecord(value) || value.version !== 0.0) {
15
+ // - If the value is an object, but doesn’t have `version: 2`, it’s not compatible with the current version
16
+ if (!isRecord(value) || value.version !== 2) {
17
17
  throw new Error('useMenu(): the context value is not compatible')
18
18
  }
19
19
 
@@ -1,4 +1,4 @@
1
- import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
1
+ import {useCallback, useEffect, useRef, useState} from 'react'
2
2
 
3
3
  import {_getFocusableElements, _sortElements} from './helpers'
4
4
 
@@ -29,12 +29,12 @@ export function useMenuController(props: {
29
29
  const elementsRef = useRef<HTMLElement[]>([])
30
30
  const [activeIndex, _setActiveIndex] = useState(-1)
31
31
  const activeIndexRef = useRef(activeIndex)
32
- const activeElement = useMemo(() => elementsRef.current[activeIndex] || null, [activeIndex])
33
- const mounted = Boolean(rootElementRef.current)
32
+ const [activeElement, setActiveElement] = useState<HTMLElement | null>(null)
34
33
 
35
34
  const setActiveIndex = useCallback((nextActiveIndex: number) => {
36
35
  _setActiveIndex(nextActiveIndex)
37
36
  activeIndexRef.current = nextActiveIndex
37
+ setActiveElement(elementsRef.current[nextActiveIndex] || null)
38
38
  }, [])
39
39
 
40
40
  const mount = useCallback(
@@ -183,7 +183,7 @@ export function useMenuController(props: {
183
183
 
184
184
  // Set focus on the currently active element
185
185
  useEffect(() => {
186
- if (!mounted) return
186
+ if (!rootElementRef.current) return
187
187
 
188
188
  const rafId = requestAnimationFrame(() => {
189
189
  if (activeIndex === -1) {
@@ -218,7 +218,7 @@ export function useMenuController(props: {
218
218
  })
219
219
 
220
220
  return () => cancelAnimationFrame(rafId)
221
- }, [activeIndex, mounted, setActiveIndex, shouldFocus])
221
+ }, [activeIndex, rootElementRef, setActiveIndex, shouldFocus])
222
222
 
223
223
  return {
224
224
  activeElement,
@@ -1,6 +1,6 @@
1
1
  import {ToggleArrowRightIcon} from '@sanity/icons'
2
2
  import {ThemeFontWeightKey} from '@sanity/ui/theme'
3
- import {memo, useCallback, useEffect, useId, useMemo, useRef} from 'react'
3
+ import {memo, useCallback, useEffect, useId, useMemo, useRef, useState} from 'react'
4
4
  import {styled} from 'styled-components'
5
5
 
6
6
  import {Box, BoxProps, Flex, Text} from '../../primitives'
@@ -65,18 +65,20 @@ export const TreeItem = memo(function TreeItem(
65
65
  weight,
66
66
  ...restProps
67
67
  } = props
68
- const rootRef = useRef<HTMLLIElement | null>(null)
68
+ const [rootElement, setRootElement] = useState<HTMLLIElement | null>(null)
69
69
  const treeitemRef = useRef<HTMLDivElement | null>(null)
70
70
  const tree = useTree()
71
71
  const {path, registerItem, setExpanded, setFocusedElement} = tree
72
72
  const _id = useId()
73
73
  const id = idProp || _id
74
- const itemPath = useMemo(() => path.concat([id || '']), [id, path])
75
- const itemKey = itemPath.join('/')
74
+ const [itemPath, itemKey] = useMemo(() => {
75
+ const itemPath = path.concat([id || ''])
76
+ return [itemPath, itemPath.join('/')]
77
+ }, [id, path])
76
78
  const itemState = tree.state[itemKey]
77
- const focused = tree.focusedElement === rootRef.current
79
+ const focused = tree.focusedElement === rootElement
78
80
  const expanded = itemState?.expanded === undefined ? expandedProp : itemState?.expanded || false
79
- const tabIndex = tree.focusedElement && tree.focusedElement === rootRef.current ? 0 : -1
81
+ const tabIndex = tree.focusedElement && tree.focusedElement === rootElement ? 0 : -1
80
82
  const contextValue = useMemo(
81
83
  () => ({...tree, level: tree.level + 1, path: itemPath}),
82
84
  [itemPath, tree],
@@ -95,28 +97,28 @@ export const TreeItem = memo(function TreeItem(
95
97
  ) {
96
98
  event.stopPropagation()
97
99
  setExpanded(itemKey, !expanded)
98
- setFocusedElement(rootRef.current)
100
+ setFocusedElement(rootElement)
99
101
  }
100
102
  },
101
- [expanded, itemKey, onClick, setExpanded, setFocusedElement],
103
+ [expanded, itemKey, onClick, rootElement, setExpanded, setFocusedElement],
102
104
  )
103
105
 
104
106
  const handleKeyDown = useCallback(
105
107
  (event: React.KeyboardEvent<HTMLElement>) => {
106
108
  if (focused && event.key === 'Enter') {
107
- const el = treeitemRef.current || rootRef.current
109
+ const el = treeitemRef.current || rootElement
108
110
 
109
111
  el?.click()
110
112
  }
111
113
  },
112
- [focused],
114
+ [focused, rootElement],
113
115
  )
114
116
 
115
117
  useEffect(() => {
116
- if (!rootRef.current) return
118
+ if (!rootElement) return
117
119
 
118
- return registerItem(rootRef.current, itemPath.join('/'), expanded, selected)
119
- }, [expanded, itemPath, registerItem, selected])
120
+ return registerItem(rootElement, itemKey, expanded, selected)
121
+ }, [expanded, itemKey, registerItem, rootElement, selected])
120
122
 
121
123
  const content = (
122
124
  <Flex padding={padding}>
@@ -155,7 +157,7 @@ export const TreeItem = memo(function TreeItem(
155
157
  data-ui="TreeItem"
156
158
  {...restProps}
157
159
  onClick={handleClick}
158
- ref={rootRef}
160
+ ref={setRootElement}
159
161
  role="none"
160
162
  >
161
163
  <TreeItemBox
@@ -188,7 +190,7 @@ export const TreeItem = memo(function TreeItem(
188
190
  aria-expanded={expanded}
189
191
  onClick={handleClick}
190
192
  onKeyDown={handleKeyDown}
191
- ref={rootRef}
193
+ ref={setRootElement}
192
194
  role="treeitem"
193
195
  tabIndex={tabIndex}
194
196
  >
@@ -1,4 +1,4 @@
1
- import {useMemo, useRef, useSyncExternalStore} from 'react'
1
+ import {useMemo, useSyncExternalStore} from 'react'
2
2
 
3
3
  import {PortalContext} from './portalContext'
4
4
  import {PortalContextValue} from './types'
@@ -23,8 +23,7 @@ export interface PortalProviderProps {
23
23
  * @public
24
24
  */
25
25
  export function PortalProvider(props: PortalProviderProps): React.JSX.Element {
26
- const {boundaryElement, children, element, __unstable_elements: elementsProp} = props
27
- const elements = useUnique(elementsProp)
26
+ const {boundaryElement, children, element, __unstable_elements: elements} = props
28
27
  const fallbackElement = useSyncExternalStore(
29
28
  emptySubscribe,
30
29
  () => document.body,
@@ -46,34 +45,3 @@ export function PortalProvider(props: PortalProviderProps): React.JSX.Element {
46
45
  PortalProvider.displayName = 'PortalProvider'
47
46
 
48
47
  const emptySubscribe = () => () => {}
49
-
50
- /**
51
- * This is a React hook to make sure that a record identity is the same on every render. Uses strict
52
- * equality comparison (eg by identity), and only goes one level deep.
53
- */
54
- function useUnique<ValueType extends Comparable = Comparable>(value: ValueType): ValueType {
55
- const valueRef = useRef<ValueType>(value)
56
-
57
- if (!_isEqual(valueRef.current, value)) {
58
- valueRef.current = value
59
- }
60
-
61
- return valueRef.current
62
- }
63
-
64
- function _isEqual(objA: Comparable, objB: Comparable): boolean {
65
- if (!objA || !objB) {
66
- return objA === objB
67
- }
68
-
69
- const keysA = Object.keys(objA)
70
- const keysB = Object.keys(objB)
71
-
72
- if (keysA.length !== keysB.length) {
73
- return false
74
- }
75
-
76
- return keysA.every((key) => objA[key] === objB[key])
77
- }
78
-
79
- type Comparable = Record<string | number | symbol, unknown> | undefined | null