@sanity/ui 2.6.4-canary.1 → 2.6.4-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.
Files changed (37) hide show
  1. package/dist/index.d.mts +42 -6
  2. package/dist/index.d.ts +42 -6
  3. package/dist/index.esm.js +90 -156
  4. package/dist/index.esm.js.map +1 -1
  5. package/dist/index.js +88 -154
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +90 -156
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +1 -1
  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/tooltip.tsx +25 -21
  32. package/src/core/primitives/tooltip/tooltipDelayGroup/tooltipDelayGroupProvider.tsx +1 -0
  33. package/src/core/utils/layer/layerProvider.tsx +1 -0
  34. package/src/core/utils/portal/__workshop__/named.tsx +10 -8
  35. package/src/core/utils/portal/portalProvider.tsx +2 -3
  36. package/src/core/hooks/_internal/index.ts +0 -1
  37. package/src/core/hooks/_internal/useUnique.ts +0 -34
@@ -1,62 +1,16 @@
1
- import {useSyncExternalStore} from 'react'
2
-
3
- let MEDIA_QUERY_CACHE: MediaQueryList | undefined
4
-
5
- /**
6
- * Lazy init the matchMedia instance
7
- */
8
- function getMatchMedia(): MediaQueryList {
9
- if (!MEDIA_QUERY_CACHE) {
10
- // As this function is only called during `subscribe` and `getSnapshot`, we can assume that the
11
- // the `window` global is available and we're in a browser environment
12
- MEDIA_QUERY_CACHE = window.matchMedia('(prefers-reduced-motion: reduce)')
13
- }
14
-
15
- return MEDIA_QUERY_CACHE
16
- }
17
-
18
- /**
19
- * As the query is the same for all instances of this hook, we can cache the matchMedia instance
20
- * and have cheap `change` event listeners, while getSnapshot always reads from the same
21
- * matchMedia instance and we don't get any tearing.
22
- * Tearing in this context means the bad edge case in React concurrent render mdoe
23
- * where you sometimes would end up with some components doing render while seeing `usePrefersDark() === true` while others would see `usePrefersDark() === false`
24
- * during the same render.
25
- * By using `useSyncExternalStore` every component only sees the same value during the same render, and always re-render when it changes no matter
26
- * what React.memo boundaries there might be between the layers..
27
- */
28
- function subscribe(onStoreChange: () => void): () => void {
29
- const matchMedia = getMatchMedia()
30
-
31
- matchMedia.addEventListener('change', onStoreChange)
32
-
33
- return () => matchMedia.removeEventListener('change', onStoreChange)
34
- }
35
-
36
- /**
37
- * Only called client-side, when using createRoot, or after hydration is complete when using hydrateRoot.
38
- * It's important that this function does not create new objects or arrays when called:
39
- * https://beta.reactjs.org/apis/react/useSyncExternalStore#im-getting-an-error-the-result-of-getsnapshot-should-be-cached
40
- */
41
- function getSnapshot() {
42
- return getMatchMedia().matches
43
- }
44
-
45
- /**
46
- * Only called during server-side rendering, and hydration if using hydrateRoot
47
- * Since the server environment doesn't have access to the DOM, we can't determine the current value of the media query
48
- * and we assume `(prefers-reduced-motion: no-preference)` since it's the most common scheme
49
- *
50
- * @link https://beta.reactjs.org/apis/react/useSyncExternalStore#adding-support-for-server-rendering
51
- */
52
- function getServerSnapshot() {
53
- return false
54
- }
1
+ import {useMatchMedia} from './useMatchMedia'
55
2
 
56
3
  /**
57
4
  * Returns true if motion should be reduced
5
+ *
6
+ * @param getServerSnapshot - Only called during server-side rendering, and hydration if using hydrateRoot. Since the server environment doesn't have access to the DOM, we can't determine the current value of the media query and we assume `(prefers-reduced-motion: no-preference)` since it's the most common scheme (https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering)
7
+ *
8
+ * If you persist the detected preference in a cookie or a header then you may implement your own server snapshot to read it.
9
+ * Chrome supports reading the `prefers-reduced-motion` media query from a header if the server response: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-Prefers-Reduced-Motion
10
+ * @example https://gist.github.com/stipsan/0c0f839a27842249cada893e9fb7767b
11
+ *
58
12
  * @public
59
13
  */
60
- export function usePrefersReducedMotion(): boolean {
61
- return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
14
+ export function usePrefersReducedMotion(getServerSnapshot = () => false): boolean {
15
+ return useMatchMedia('(prefers-reduced-motion: reduce)', getServerSnapshot)
62
16
  }
@@ -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>
@@ -10,7 +10,7 @@ import {
10
10
  Tooltip,
11
11
  } from '@sanity/ui'
12
12
  import {useBoolean, useSelect, useText} from '@sanity/ui-workshop'
13
- import {useState} from 'react'
13
+ import {useMemo, useState} from 'react'
14
14
  import {WORKSHOP_PLACEMENT_OPTIONS} from '../../../__workshop__/constants'
15
15
 
16
16
  const PORTAL_OPTIONS = {
@@ -30,13 +30,15 @@ export default function CustomPortalStory() {
30
30
 
31
31
  const [portal1Element, setPortal1Element] = useState<HTMLDivElement | null>(null)
32
32
  const [boundaryElement, setBoundaryElement] = useState<HTMLDivElement | null>(null)
33
+ const __unstable_elements = useMemo(
34
+ () => ({
35
+ portal1: portal1Element,
36
+ }),
37
+ [portal1Element],
38
+ )
33
39
 
34
40
  return (
35
- <PortalProvider
36
- __unstable_elements={{
37
- portal1: portal1Element,
38
- }}
39
- >
41
+ <PortalProvider __unstable_elements={__unstable_elements}>
40
42
  <Flex align="center" height="fill" justify="center">
41
43
  <BoundaryElementProvider element={useBoundaryElement ? boundaryElement : null}>
42
44
  <Card
@@ -5,6 +5,7 @@ import {
5
5
  flip,
6
6
  offset,
7
7
  shift,
8
+ size,
8
9
  useFloating,
9
10
  type Middleware,
10
11
  type RootBoundary,
@@ -26,7 +27,6 @@ import {styled} from 'styled-components'
26
27
  import {useEffectEvent} from 'use-effect-event'
27
28
  import {useArrayProp, usePrefersReducedMotion} from '../../hooks'
28
29
  import {useDelayedState} from '../../hooks/useDelayedState'
29
- import {useMounted} from '../../hooks/useMounted'
30
30
  import {origin} from '../../middleware/origin'
31
31
  import {useTheme_v2} from '../../theme'
32
32
  import type {Placement} from '../../types'
@@ -86,9 +86,8 @@ export interface TooltipProps extends Omit<LayerProps, 'as'> {
86
86
  animate?: boolean
87
87
  }
88
88
 
89
- const Root = styled(Layer)<{$maxWidth: number}>`
89
+ const Root = styled(Layer)`
90
90
  pointer-events: none;
91
- max-width: ${({$maxWidth}) => $maxWidth}px;
92
91
  `
93
92
 
94
93
  /**
@@ -135,25 +134,31 @@ export const Tooltip = forwardRef(function Tooltip(
135
134
  const portalElement =
136
135
  typeof portalProp === 'string' ? portal.elements?.[portalProp] || null : portal.element
137
136
 
138
- const mounted = useMounted()
139
- // Get the maximum tooltip width (sans tooltip padding)
140
- // Tooltip width should never exceed the width of either any supplied boundary or portal element.
141
- // If both portal and boundary elements are provided, use the smaller width of the two.
142
- const tooltipWidth = useMemo(() => {
143
- const availableWidths = [
144
- ...(boundaryElement ? [boundaryElement.offsetWidth] : []),
145
- portalElement?.offsetWidth || mounted
146
- ? document.body.offsetWidth
147
- : // We don't actually know what the width of the body is during SSR, so we'll just assume it's 800px or larger
148
- 800,
149
- ]
150
-
151
- return Math.min(...availableWidths) - DEFAULT_TOOLTIP_PADDING * 2
152
- }, [boundaryElement, mounted, portalElement])
153
-
154
137
  const middleware = useMemo(() => {
155
138
  const ret: Middleware[] = []
156
139
 
140
+ // Set the max width of the tooltip based on boundaries and portals
141
+ ret.push(
142
+ size({
143
+ apply({elements}) {
144
+ // Get the maximum tooltip width (sans tooltip padding)
145
+ // Tooltip width should never exceed the width of either any supplied boundary or portal element.
146
+ // If both portal and boundary elements are provided, use the smaller width of the two.
147
+ const availableWidths = [
148
+ ...(boundaryElement ? [boundaryElement.offsetWidth] : []),
149
+ portalElement?.offsetWidth || document.body.offsetWidth,
150
+ ]
151
+
152
+ const tooltipWidth = Math.min(...availableWidths) - DEFAULT_TOOLTIP_PADDING * 2
153
+
154
+ // Set the max width directly, efficiently, without another react effect + render loop
155
+ Object.assign(elements.floating.style, {
156
+ maxWidth: tooltipWidth > 0 ? `${tooltipWidth}px` : undefined,
157
+ })
158
+ },
159
+ }),
160
+ )
161
+
157
162
  // Flip the floating element when leaving the boundary box
158
163
  ret.push(
159
164
  flip({
@@ -188,7 +193,7 @@ export const Tooltip = forwardRef(function Tooltip(
188
193
  }
189
194
 
190
195
  return ret
191
- }, [animate, arrowProp, boundaryElement, fallbackPlacements])
196
+ }, [animate, arrowProp, boundaryElement, fallbackPlacements, portalElement])
192
197
 
193
198
  const {floatingStyles, placement, middlewareData, refs, update} = useFloating({
194
199
  middleware,
@@ -381,7 +386,6 @@ export const Tooltip = forwardRef(function Tooltip(
381
386
  ref={setFloating}
382
387
  style={floatingStyles}
383
388
  zOffset={zOffset}
384
- $maxWidth={tooltipWidth}
385
389
  >
386
390
  <TooltipCard
387
391
  {...restProps}
@@ -36,6 +36,7 @@ export function TooltipDelayGroupProvider(
36
36
  const openDelay = typeof delay === 'number' ? delay : delay?.open || 0
37
37
  const closeDelay = typeof delay === 'number' ? delay : delay?.close || 0
38
38
 
39
+ // @TODO split out into separate contexts
39
40
  const value: TooltipDelayGroupContextValue = useMemo(
40
41
  () => ({
41
42
  isGroupActive: isGroupActive,
@@ -91,6 +91,7 @@ export function LayerProvider(props: LayerProviderProps): React.ReactElement {
91
91
  // Register this layer on mount
92
92
  useEffect(() => parentRegisterChild?.(level), [level, parentRegisterChild])
93
93
 
94
+ // @TODO split out into separate contexts
94
95
  const value: LayerContextValue = useMemo(
95
96
  () => ({
96
97
  version: 0.0,
@@ -1,19 +1,21 @@
1
1
  import {Card, Container, Portal, PortalProvider, Stack, Text} from '@sanity/ui'
2
- import {useState} from 'react'
2
+ import {useMemo, useState} from 'react'
3
3
 
4
4
  export default function NamedStory() {
5
5
  const [portal1Element, setPortal1Element] = useState<HTMLDivElement | null>(null)
6
6
  const [portal2Element, setPortal2Element] = useState<HTMLDivElement | null>(null)
7
7
  const [portal3Element, setPortal3Element] = useState<HTMLDivElement | null>(null)
8
+ const __unstable_elements = useMemo(
9
+ () => ({
10
+ portal1: portal1Element,
11
+ portal2: portal2Element,
12
+ portal3: portal3Element,
13
+ }),
14
+ [portal1Element, portal2Element, portal3Element],
15
+ )
8
16
 
9
17
  return (
10
- <PortalProvider
11
- __unstable_elements={{
12
- portal1: portal1Element,
13
- portal2: portal2Element,
14
- portal3: portal3Element,
15
- }}
16
- >
18
+ <PortalProvider __unstable_elements={__unstable_elements}>
17
19
  <Container width={1}>
18
20
  <Card height="fill" padding={4}>
19
21
  <Stack space={2}>
@@ -1,5 +1,4 @@
1
1
  import {useMemo} from 'react'
2
- import {useUnique} from '../../hooks/_internal'
3
2
  import {useMounted} from '../../hooks/useMounted'
4
3
  import {PortalContext} from './portalContext'
5
4
  import {PortalContextValue} from './types'
@@ -24,10 +23,10 @@ export interface PortalProviderProps {
24
23
  * @public
25
24
  */
26
25
  export function PortalProvider(props: PortalProviderProps): React.ReactElement {
27
- const {boundaryElement, children, element, __unstable_elements: elementsProp} = props
28
- const elements = useUnique(elementsProp)
26
+ const {boundaryElement, children, element, __unstable_elements: elements} = props
29
27
  const mounted = useMounted()
30
28
 
29
+ // @TODO split out into separate contexts
31
30
  const value: PortalContextValue = useMemo(() => {
32
31
  return {
33
32
  version: 0.0,
@@ -1 +0,0 @@
1
- export * from './useUnique'
@@ -1,34 +0,0 @@
1
- import {useRef} from 'react'
2
-
3
- /**
4
- * This is a React hook to make sure that a record identity is the same on every render. Uses strict
5
- * equality comparison (eg by identity), and only goes one level deep.
6
- *
7
- * @internal
8
- */
9
- type Comparable = Record<string | number | symbol, unknown> | undefined | null
10
-
11
- export function useUnique<ValueType extends Comparable = Comparable>(value: ValueType): ValueType {
12
- const valueRef = useRef<ValueType>(value)
13
-
14
- if (!_isEqual(valueRef.current, value)) {
15
- valueRef.current = value
16
- }
17
-
18
- return valueRef.current
19
- }
20
-
21
- function _isEqual(objA: Comparable, objB: Comparable): boolean {
22
- if (!objA || !objB) {
23
- return objA === objB
24
- }
25
-
26
- const keysA = Object.keys(objA)
27
- const keysB = Object.keys(objB)
28
-
29
- if (keysA.length !== keysB.length) {
30
- return false
31
- }
32
-
33
- return keysA.every((key) => objA[key] === objB[key])
34
- }