@sanity/ui 1.1.0-beta.2 → 1.2.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.
- package/LICENSE +1 -1
- package/dist/index.cjs.mjs +120 -0
- package/dist/index.d.ts +26 -4
- package/dist/{index.cjs → index.esm.js} +1987 -1946
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +2114 -1819
- package/dist/index.js.map +1 -1
- package/package.json +47 -44
- package/src/components/dialog/__workshop__/activate.tsx +134 -0
- package/src/components/dialog/__workshop__/index.ts +3 -0
- package/src/components/dialog/__workshop__/nested.tsx +14 -3
- package/src/components/dialog/__workshop__/panes.tsx +86 -0
- package/src/components/dialog/__workshop__/wrapped.tsx +78 -0
- package/src/components/dialog/dialog.tsx +110 -25
- package/src/components/toast/toastProvider.tsx +55 -52
- package/src/constants.ts +2 -2
- package/src/helpers/element.ts +7 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useArrayProp.ts +2 -1
- package/src/hooks/useMediaIndex/useMediaIndex.test.tsx +31 -0
- package/src/hooks/useMediaIndex/useMediaIndex.ts +98 -6
- package/src/hooks/useMounted.ts +14 -0
- package/src/hooks/usePrefersDark.hydration.test.tsx +56 -0
- package/src/hooks/usePrefersDark.test.tsx +18 -0
- package/src/hooks/usePrefersDark.ts +51 -18
- package/src/hooks/usePrefersReducedMotion.hydration.test.tsx +56 -0
- package/src/hooks/usePrefersReducedMotion.test.tsx +18 -0
- package/src/hooks/usePrefersReducedMotion.ts +62 -0
- package/src/observers/resizeObserver.ts +1 -1
- package/src/primitives/code/styles.ts +6 -0
- package/src/primitives/heading/styles.ts +6 -0
- package/src/primitives/label/styles.ts +6 -0
- package/src/primitives/popover/__workshop__/OpenOnMountStory.tsx +11 -0
- package/src/primitives/popover/__workshop__/index.ts +5 -0
- package/src/primitives/text/styles.ts +6 -0
- package/src/utils/layer/__workshop__/_debug.tsx +9 -3
- package/src/utils/layer/__workshop__/index.ts +3 -3
- package/src/utils/layer/__workshop__/multipleRoots.tsx +2 -1
- package/src/utils/layer/__workshop__/{plain.tsx → nested.tsx} +14 -4
- package/src/utils/layer/getLayerContext.test.ts +30 -0
- package/src/utils/layer/getLayerContext.ts +21 -0
- package/src/utils/layer/layer.test.tsx +1 -0
- package/src/utils/layer/layer.tsx +49 -7
- package/src/utils/layer/layerContext.ts +2 -2
- package/src/utils/layer/layerProvider.tsx +73 -12
- package/src/utils/layer/types.ts +3 -4
- package/src/utils/layer/useLayer.ts +9 -8
- package/src/utils/portal/portalContext.ts +1 -1
- package/src/utils/portal/portalProvider.tsx +1 -1
- package/dist/index.cjs.map +0 -1
- package/src/hooks/useMediaIndex/lib/media.ts +0 -88
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
import {renderToString, renderToStaticMarkup} from 'react-dom/server'
|
|
3
|
+
import {usePrefersDark} from './usePrefersDark'
|
|
4
|
+
|
|
5
|
+
function Log() {
|
|
6
|
+
const dark = usePrefersDark()
|
|
7
|
+
|
|
8
|
+
return <>dark: {JSON.stringify(dark)}</>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('usePrefersDark', () => {
|
|
12
|
+
it(`SSR to static markup returns false`, () => {
|
|
13
|
+
expect(renderToStaticMarkup(<Log />)).toBe('dark: false')
|
|
14
|
+
})
|
|
15
|
+
it(`SSR to markup for hydration doesn't throw`, () => {
|
|
16
|
+
expect(renderToString(<Log />)).toMatchInlineSnapshot(`"dark: <!-- -->false"`)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
@@ -1,28 +1,61 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {useSyncExternalStore} from 'react'
|
|
2
|
+
|
|
3
|
+
let MEDIA_QUERY_CACHE: MediaQueryList | undefined
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
|
-
*
|
|
6
|
+
* Lazy init the matchMedia instance
|
|
5
7
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
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-color-scheme: dark)')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return MEDIA_QUERY_CACHE
|
|
16
|
+
}
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
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()
|
|
17
30
|
|
|
18
|
-
|
|
31
|
+
matchMedia.addEventListener('change', onStoreChange)
|
|
19
32
|
|
|
20
|
-
|
|
33
|
+
return () => matchMedia.removeEventListener('change', onStoreChange)
|
|
34
|
+
}
|
|
21
35
|
|
|
22
|
-
|
|
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
|
+
}
|
|
23
44
|
|
|
24
|
-
|
|
25
|
-
|
|
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-color-scheme: light)` 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
|
+
}
|
|
26
55
|
|
|
27
|
-
|
|
56
|
+
/**
|
|
57
|
+
* @public
|
|
58
|
+
*/
|
|
59
|
+
export function usePrefersDark(): boolean {
|
|
60
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
|
28
61
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
/**
|
|
3
|
+
* As this hook is used for top-level theming it's likely to be called while server-rendering
|
|
4
|
+
* and that's why it's worth it to have a testing suite for hydration
|
|
5
|
+
*/
|
|
6
|
+
import {waitFor} from '@testing-library/dom'
|
|
7
|
+
import {hydrateRoot} from 'react-dom/client'
|
|
8
|
+
|
|
9
|
+
import {usePrefersReducedMotion} from './usePrefersReducedMotion'
|
|
10
|
+
|
|
11
|
+
function Log() {
|
|
12
|
+
const reduceMotion = usePrefersReducedMotion()
|
|
13
|
+
|
|
14
|
+
return <>prefers-motion: {JSON.stringify(reduceMotion)}</>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const originalMatchMedia = window.matchMedia
|
|
18
|
+
|
|
19
|
+
describe('usePrefersReducedMotion SSR hydration', () => {
|
|
20
|
+
beforeAll(() => {
|
|
21
|
+
window.matchMedia = () =>
|
|
22
|
+
({
|
|
23
|
+
addEventListener: jest.fn(),
|
|
24
|
+
removeEventListener: jest.fn(),
|
|
25
|
+
matches: true,
|
|
26
|
+
} as any)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterAll(() => {
|
|
30
|
+
window.matchMedia = originalMatchMedia
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it(`hydrates without any warnings`, async () => {
|
|
34
|
+
const spy = jest.spyOn(console, 'error').mockImplementation()
|
|
35
|
+
|
|
36
|
+
const node = document.createElement('div')
|
|
37
|
+
|
|
38
|
+
document.body.appendChild(node)
|
|
39
|
+
|
|
40
|
+
node.innerHTML = `prefers-motion: <!-- -->false`
|
|
41
|
+
|
|
42
|
+
hydrateRoot(node, <Log />)
|
|
43
|
+
|
|
44
|
+
// It's false initially
|
|
45
|
+
await waitFor(() => expect(node.innerHTML).toBe('prefers-motion: <!-- -->false'))
|
|
46
|
+
|
|
47
|
+
// After hydration it should switch to true
|
|
48
|
+
await waitFor(() => expect(node.innerHTML).toBe('prefers-motion: <!-- -->true'))
|
|
49
|
+
|
|
50
|
+
// eslint-disable-next-line no-console
|
|
51
|
+
expect(console.error).not.toHaveBeenCalled()
|
|
52
|
+
|
|
53
|
+
spy.mockReset()
|
|
54
|
+
spy.mockRestore()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
import {renderToString, renderToStaticMarkup} from 'react-dom/server'
|
|
3
|
+
import {usePrefersReducedMotion} from './usePrefersReducedMotion'
|
|
4
|
+
|
|
5
|
+
function Log() {
|
|
6
|
+
const reduceMotion = usePrefersReducedMotion()
|
|
7
|
+
|
|
8
|
+
return <>prefers-motion: {JSON.stringify(reduceMotion)}</>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('usePrefersReducedMotion', () => {
|
|
12
|
+
it(`SSR to static markup returns false`, () => {
|
|
13
|
+
expect(renderToStaticMarkup(<Log />)).toBe('prefers-motion: false')
|
|
14
|
+
})
|
|
15
|
+
it(`SSR to markup for hydration doesn't throw`, () => {
|
|
16
|
+
expect(renderToString(<Log />)).toMatchInlineSnapshot(`"prefers-motion: <!-- -->false"`)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Returns true if motion should be reduced
|
|
58
|
+
* @public
|
|
59
|
+
*/
|
|
60
|
+
export function usePrefersReducedMotion(): boolean {
|
|
61
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
|
62
|
+
}
|
|
@@ -4,6 +4,6 @@ import {ResizeObserver as ResizeObserverPolyfill} from '@juggle/resize-observer'
|
|
|
4
4
|
* @internal
|
|
5
5
|
*/
|
|
6
6
|
export const _ResizeObserver: typeof ResizeObserver =
|
|
7
|
-
typeof
|
|
7
|
+
typeof document !== 'undefined' && window.ResizeObserver
|
|
8
8
|
? window.ResizeObserver
|
|
9
9
|
: ResizeObserverPolyfill
|
|
@@ -63,6 +63,12 @@ export function codeBaseStyle(): FlattenInterpolation<StyledThemeProps<Theme>> {
|
|
|
63
63
|
border-radius: 1px;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
& svg {
|
|
67
|
+
/* Certain popular CSS libraries changes the defaults for SVG display */
|
|
68
|
+
/* Make sure SVGs are rendered as inline elements */
|
|
69
|
+
display: inline;
|
|
70
|
+
}
|
|
71
|
+
|
|
66
72
|
& [data-sanity-icon] {
|
|
67
73
|
vertical-align: baseline;
|
|
68
74
|
}
|
|
@@ -44,6 +44,12 @@ export function headingBaseStyle(
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
& svg {
|
|
48
|
+
/* Certain popular CSS libraries changes the defaults for SVG display */
|
|
49
|
+
/* Make sure SVGs are rendered as inline elements */
|
|
50
|
+
display: inline;
|
|
51
|
+
}
|
|
52
|
+
|
|
47
53
|
& [data-sanity-icon] {
|
|
48
54
|
vertical-align: baseline;
|
|
49
55
|
}
|
|
@@ -30,6 +30,12 @@ export function labelBaseStyle(
|
|
|
30
30
|
border-radius: 1px;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
& svg {
|
|
34
|
+
/* Certain popular CSS libraries changes the defaults for SVG display */
|
|
35
|
+
/* Make sure SVGs are rendered as inline elements */
|
|
36
|
+
display: inline;
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
& [data-sanity-icon] {
|
|
34
40
|
vertical-align: baseline;
|
|
35
41
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {Button, Card, Popover, Text} from '@sanity/ui'
|
|
2
|
+
|
|
3
|
+
export default function OpenOnMountStory() {
|
|
4
|
+
return (
|
|
5
|
+
<Card height="fill" padding={3} sizing="border">
|
|
6
|
+
<Popover content={<Text>popover content</Text>} open padding={3}>
|
|
7
|
+
<Button text="This button is the popover reference" />
|
|
8
|
+
</Popover>
|
|
9
|
+
</Card>
|
|
10
|
+
)
|
|
11
|
+
}
|
|
@@ -52,6 +52,12 @@ export function textBaseStyle(
|
|
|
52
52
|
font-weight: ${weights.bold};
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
& svg {
|
|
56
|
+
/* Certain popular CSS libraries changes the defaults for SVG display */
|
|
57
|
+
/* Make sure SVGs are rendered as inline elements */
|
|
58
|
+
display: inline;
|
|
59
|
+
}
|
|
60
|
+
|
|
55
61
|
& [data-sanity-icon] {
|
|
56
62
|
vertical-align: baseline;
|
|
57
63
|
}
|
|
@@ -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
|
-
|
|
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: '
|
|
10
|
-
title: '
|
|
11
|
-
component: lazy(() => import('./
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
|
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:
|
|
9
|
+
export const LayerContext: Context<LayerContextValue | null> = globalScope[key]
|