@sanity/ui 1.1.0 → 1.2.1

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.
@@ -46,3 +46,10 @@ export function isHTMLSelectElement(element: unknown): element is HTMLSelectElem
46
46
  export function isHTMLTextAreaElement(element: unknown): element is HTMLTextAreaElement {
47
47
  return isHTMLElement(element) && element.nodeName === 'TEXTAREA'
48
48
  }
49
+
50
+ /**
51
+ * @internal
52
+ */
53
+ export function containsOrEqualsElement(element: HTMLElement, node: Node): boolean {
54
+ return element.contains(node) || element === node
55
+ }
@@ -1,11 +1,17 @@
1
1
  import {Code, useLayer} from '@sanity/ui'
2
2
 
3
- export function LayerDebugInfo() {
3
+ export function LayerDebugInfo(props: {id?: string}) {
4
+ const {id} = props
4
5
  const layer = useLayer()
5
6
 
6
7
  return (
7
- <Code>
8
- zIndex={layer.zIndex}, size={layer.size}
8
+ <Code id={id}>
9
+ {[
10
+ //
11
+ `isTopLayer=${layer.isTopLayer}`,
12
+ `size=${layer.size}`,
13
+ `zIndex=${layer.zIndex}`,
14
+ ].join('\n')}
9
15
  </Code>
10
16
  )
11
17
  }
@@ -6,9 +6,9 @@ export default defineScope({
6
6
  title: 'Layer',
7
7
  stories: [
8
8
  {
9
- name: 'plain',
10
- title: 'Plain',
11
- component: lazy(() => import('./plain')),
9
+ name: 'nested',
10
+ title: 'Nested',
11
+ component: lazy(() => import('./nested')),
12
12
  },
13
13
  {
14
14
  name: 'multiple-roots',
@@ -23,8 +23,9 @@ export default function MultipleRootsStory() {
23
23
  </Stack>
24
24
  </Card>
25
25
  </LayerProvider>
26
+
26
27
  <LayerProvider zOffset={200}>
27
- <Card as={Layer as any} padding={3} shadow={5} style={{top: -50, left: 30}}>
28
+ <Card as={Layer} padding={3} shadow={5} style={{top: -50, left: 30}}>
28
29
  <Stack space={3}>
29
30
  <LayerDebugInfo />
30
31
  <Layer>
@@ -4,13 +4,23 @@ import {useCallback, useState} from 'react'
4
4
  import {LayerDebugInfo} from './_debug'
5
5
 
6
6
  export default function PlainStory() {
7
+ return (
8
+ <Box padding={3}>
9
+ <Root />
10
+ <Root />
11
+ <Root />
12
+ </Box>
13
+ )
14
+ }
15
+
16
+ function Root() {
7
17
  const [open, setOpen] = useState(false)
8
18
  const handleOpen = useCallback(() => setOpen(true), [])
9
19
  const handleClose = useCallback(() => setOpen(false), [])
10
20
 
11
21
  return (
12
22
  <LayerProvider>
13
- <Card radius={2}>
23
+ <Card radius={2} shadow={1}>
14
24
  <Box padding={3}>
15
25
  <Text>
16
26
  <strong>Root layer</strong>
@@ -18,8 +28,8 @@ export default function PlainStory() {
18
28
  </Box>
19
29
  <Box padding={3}>
20
30
  <Stack space={3}>
21
- <LayerDebugInfo />
22
- <Button mode="ghost" onClick={handleOpen} text="Open layer 1" />
31
+ <LayerDebugInfo id="layer-debug-info-1" />
32
+ <Button id="open-layer-1" mode="ghost" onClick={handleOpen} text="Open layer 1" />
23
33
  {open && (
24
34
  <Layer style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
25
35
  <Layer1 onClose={handleClose} />
@@ -52,7 +62,7 @@ function Layer1({onClose}: {onClose: () => void}) {
52
62
  <Box padding={3}>
53
63
  <Stack space={3}>
54
64
  <LayerDebugInfo />
55
- <Button mode="ghost" onClick={handleOpen} text="Open layer 2" />
65
+ <Button id="open-layer-2" mode="ghost" onClick={handleOpen} text="Open layer 2" />
56
66
  {open && (
57
67
  <Layer style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
58
68
  <Layer2 onClose={handleClose} />
@@ -0,0 +1,30 @@
1
+ import {getLayerContext} from './getLayerContext'
2
+ import {LayerContextValue} from './types'
3
+
4
+ describe('getLayerContext', () => {
5
+ describe('0.0', () => {
6
+ it('should convert 0.0 to 0.0', () => {
7
+ const contextValue: LayerContextValue = {
8
+ version: 0.0,
9
+ isTopLayer: true,
10
+ level: 0,
11
+ registerChild: () => () => {
12
+ //
13
+ },
14
+ size: 0,
15
+ zIndex: 0,
16
+ }
17
+
18
+ const result = getLayerContext(contextValue)
19
+
20
+ expect(result).toEqual({
21
+ version: 0.0,
22
+ isTopLayer: true,
23
+ level: 0,
24
+ registerChild: expect.any(Function),
25
+ size: 0,
26
+ zIndex: 0,
27
+ })
28
+ })
29
+ })
30
+ })
@@ -0,0 +1,21 @@
1
+ import {isRecord} from '../../lib/isRecord'
2
+ import {LayerContextValue} from './types'
3
+
4
+ export function getLayerContext(contextValue: LayerContextValue): LayerContextValue {
5
+ // NOTE: This check is for future-compatiblity
6
+ // - If the value is not an object, it’s not compatible with the current version
7
+ // - If the value is an object, but doesn’t have `version: 0.0`, it’s not compatible with the current version
8
+ if (!isRecord(contextValue) || contextValue.version !== 0.0) {
9
+ throw new Error('the context value is not compatible')
10
+ }
11
+
12
+ if (!contextValue) {
13
+ throw new Error('components using `useLayer()` should be wrapped in a <LayerProvider>.')
14
+ }
15
+
16
+ if (contextValue.version === 0.0) {
17
+ return contextValue
18
+ }
19
+
20
+ throw new Error('could not get layer context')
21
+ }
@@ -22,6 +22,7 @@ describe('utils/layer', () => {
22
22
  const value: LayerContextValue = {
23
23
  version: 0.0,
24
24
  isTopLayer: true,
25
+ level: 0,
25
26
  registerChild: () => () => undefined,
26
27
  size: 0,
27
28
  zIndex: 0,
@@ -1,6 +1,8 @@
1
- import {forwardRef} from 'react'
1
+ import {FocusEvent, forwardRef, useCallback, useEffect, useRef} from 'react'
2
2
  import styled from 'styled-components'
3
3
  import {EMPTY_RECORD} from '../../constants'
4
+ import {containsOrEqualsElement, isHTMLElement} from '../../helpers'
5
+ import {useForwardedRef} from '../../hooks'
4
6
  import {LayerProvider} from './layerProvider'
5
7
  import {useLayer} from './useLayer'
6
8
 
@@ -9,26 +11,66 @@ import {useLayer} from './useLayer'
9
11
  */
10
12
  export interface LayerProps {
11
13
  as?: React.ElementType | keyof JSX.IntrinsicElements
14
+ /** A callback that fires when the layer becomes the top layer when it was not the top layer before. */
15
+ onActivate?: (props: {activeElement: HTMLElement | null}) => void
12
16
  zOffset?: number | number[]
13
17
  }
14
18
 
15
19
  interface LayerChildrenProps {
16
20
  as?: React.ElementType | keyof JSX.IntrinsicElements
21
+ onActivate?: LayerProps['onActivate']
17
22
  }
18
23
 
19
- const Root = styled.div`
20
- position: relative;
21
- `
24
+ const Root = styled.div({position: 'relative'})
22
25
 
23
26
  const LayerChildren = forwardRef(function LayerChildren(
24
27
  props: LayerChildrenProps & Omit<React.HTMLProps<HTMLDivElement>, 'as'>,
25
28
  ref: React.Ref<HTMLDivElement>
26
29
  ) {
27
- const {children, style = EMPTY_RECORD, ...restProps} = props
28
- const {zIndex} = useLayer()
30
+ const {children, onActivate, onFocus, style = EMPTY_RECORD, ...restProps} = props
31
+ const {zIndex, isTopLayer} = useLayer()
32
+ const lastFocusedRef = useRef<HTMLElement | null>(null)
33
+ const forwardedRef = useForwardedRef(ref)
34
+ const isTopLayerRef = useRef<boolean>(isTopLayer)
35
+
36
+ // When the layer very first mounts, it will be the top layer, but we don't want to fire
37
+ // the callback in that case. We use a ref to track the previous value of isTopLayer to
38
+ // determine if the layer has become the top layer since the last render.
39
+ useEffect(() => {
40
+ const becameTopLayer = isTopLayerRef.current !== isTopLayer && isTopLayer
41
+
42
+ if (becameTopLayer) {
43
+ onActivate?.({activeElement: lastFocusedRef.current})
44
+ }
45
+
46
+ isTopLayerRef.current = isTopLayer
47
+ }, [isTopLayer, onActivate])
48
+
49
+ const handleFocus = useCallback(
50
+ (event: FocusEvent<HTMLDivElement, Element>) => {
51
+ // Call the user-provided onFocus handler if any
52
+ onFocus?.(event)
53
+
54
+ const rootElement = forwardedRef.current
55
+ const target = document.activeElement
56
+
57
+ if (!isTopLayer || !rootElement || !target) return
58
+
59
+ if (isHTMLElement(target) && containsOrEqualsElement(rootElement, target)) {
60
+ lastFocusedRef.current = target
61
+ }
62
+ },
63
+ [forwardedRef, isTopLayer, onFocus]
64
+ )
29
65
 
30
66
  return (
31
- <Root {...restProps} data-ui="Layer" ref={ref} style={{...style, zIndex}}>
67
+ <Root
68
+ {...restProps}
69
+ data-ui="Layer"
70
+ onFocus={handleFocus}
71
+ ref={forwardedRef}
72
+ style={{...style, zIndex}}
73
+ >
32
74
  {children}
33
75
  </Root>
34
76
  )
@@ -1,4 +1,4 @@
1
- import {createContext} from 'react'
1
+ import {Context, createContext} from 'react'
2
2
  import {globalScope} from '../../lib/globalScope'
3
3
  import {LayerContextValue} from './types'
4
4
 
@@ -6,4 +6,4 @@ const key = Symbol.for('@sanity/ui/context/layer')
6
6
 
7
7
  globalScope[key] = globalScope[key] || createContext<LayerContextValue | null>(null)
8
8
 
9
- export const LayerContext: React.Context<LayerContextValue | null> = globalScope[key]
9
+ export const LayerContext: Context<LayerContextValue | null> = globalScope[key]
@@ -1,5 +1,6 @@
1
1
  import {useCallback, useContext, useEffect, useMemo, useState} from 'react'
2
2
  import {useMediaIndex, useArrayProp} from '../../hooks'
3
+ import {getLayerContext} from './getLayerContext'
3
4
  import {LayerContext} from './layerContext'
4
5
  import {LayerContextValue} from './types'
5
6
 
@@ -16,30 +17,90 @@ export interface LayerProviderProps {
16
17
  */
17
18
  export function LayerProvider(props: LayerProviderProps): React.ReactElement {
18
19
  const {children, zOffset: zOffsetProp = 0} = props
19
- const parent = useContext(LayerContext)
20
+
21
+ // Get parent context values
22
+ const parentContextValue = useContext(LayerContext)
23
+ const parent = parentContextValue && getLayerContext(parentContextValue)
24
+ const parentRegisterChild = parent?.registerChild
25
+ const parentLevel = parent?.level ?? 0
26
+
27
+ // Get level
28
+ const level = parentLevel + 1
29
+
30
+ // Get z-index offset
20
31
  const zOffset = useArrayProp(zOffsetProp)
32
+
33
+ // Get responsive z-index value
21
34
  const maxMediaIndex = zOffset.length - 1
22
35
  const mediaIndex = Math.min(useMediaIndex(), maxMediaIndex)
23
36
  const zIndex = parent ? parent.zIndex + zOffset[mediaIndex] : zOffset[mediaIndex]
37
+
38
+ // A state value that is used to keep track of the number of child layers on each level
39
+ const [, setChildLayers] = useState<Record<number, number>>({})
40
+
41
+ // A state value that is used to keep track of the number of child levels
24
42
  const [size, setSize] = useState(0)
25
43
 
26
- const registerChild = useCallback(() => {
27
- setSize((v) => v + 1)
44
+ const isTopLayer = size === 0
28
45
 
29
- return () => setSize((v) => v - 1)
30
- }, [])
46
+ const registerChild = useCallback(
47
+ (childLevel?: number) => {
48
+ // Register child layers to the parent layer
49
+ const parentDispose = parentRegisterChild?.(childLevel)
31
50
 
32
- const parentRegisterChild = parent?.registerChild
51
+ if (childLevel !== undefined) {
52
+ setChildLayers((state) => {
53
+ const prevLen = state[childLevel] ?? 0
54
+ const nextState = {...state, [childLevel]: prevLen + 1}
33
55
 
34
- useEffect(() => {
35
- if (!parentRegisterChild) return
56
+ setSize(Object.keys(nextState).length)
57
+
58
+ return nextState
59
+ })
60
+ } else {
61
+ // Legacy behavior: if no child level is provided, increment the size by 1
62
+ setSize((v) => v + 1)
63
+ }
64
+
65
+ return () => {
66
+ if (childLevel !== undefined) {
67
+ setChildLayers((state) => {
68
+ const nextState = {...state}
69
+
70
+ if (nextState[childLevel] === 1) {
71
+ delete nextState[childLevel]
72
+
73
+ setSize(Object.keys(nextState).length)
74
+ } else {
75
+ nextState[childLevel] -= 1
76
+ }
77
+
78
+ return nextState
79
+ })
80
+ } else {
81
+ // Legacy behavior: if no child level is provided, decrement the size by 1
82
+ setSize((v) => v - 1)
83
+ }
84
+
85
+ parentDispose?.()
86
+ }
87
+ },
88
+ [parentRegisterChild]
89
+ )
36
90
 
37
- return parentRegisterChild()
38
- }, [parentRegisterChild])
91
+ // Register this layer on mount
92
+ useEffect(() => parentRegisterChild?.(level), [level, parentRegisterChild])
39
93
 
40
94
  const value: LayerContextValue = useMemo(
41
- () => ({version: 0.0, isTopLayer: size === 0, registerChild, size, zIndex}),
42
- [size, registerChild, zIndex]
95
+ () => ({
96
+ version: 0.0,
97
+ isTopLayer,
98
+ level,
99
+ registerChild,
100
+ size,
101
+ zIndex,
102
+ }),
103
+ [isTopLayer, level, registerChild, size, zIndex]
43
104
  )
44
105
 
45
106
  return <LayerContext.Provider value={value}>{children}</LayerContext.Provider>
@@ -1,10 +1,9 @@
1
- /**
2
- * @public
3
- */
1
+ /** @public */
4
2
  export interface LayerContextValue {
5
3
  version: 0.0
6
4
  isTopLayer: boolean
7
- registerChild: () => () => void
5
+ level?: number
6
+ registerChild: (childLevel?: number) => () => void
8
7
  size: number
9
8
  zIndex: number
10
9
  }
@@ -1,5 +1,5 @@
1
1
  import {useContext} from 'react'
2
- import {isRecord} from '../../lib/isRecord'
2
+ import {getLayerContext} from './getLayerContext'
3
3
  import {LayerContext} from './layerContext'
4
4
  import {LayerContextValue} from './types'
5
5
 
@@ -13,12 +13,13 @@ export function useLayer(): LayerContextValue {
13
13
  throw new Error('useLayer(): missing context value')
14
14
  }
15
15
 
16
- // NOTE: This check is for future-compatiblity
17
- // - If the value is not an object, it’s not compatible with the current version
18
- // - If the value is an object, but doesn’t have `version: 0.0`, it’s not compatible with the current version
19
- if (!isRecord(value) || value.version !== 0.0) {
20
- throw new Error('useLayer(): the context value is not compatible')
16
+ try {
17
+ return getLayerContext(value)
18
+ } catch (err) {
19
+ if (err instanceof Error) {
20
+ throw new Error(`useLayer(): ${err.message}`)
21
+ } else {
22
+ throw new Error(`useLayer(): ${err}`)
23
+ }
21
24
  }
22
-
23
- return value
24
25
  }