@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
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import {CloseIcon} from '@sanity/icons'
|
|
2
2
|
import {forwardRef, useCallback, useEffect, useRef, useState} from 'react'
|
|
3
3
|
import styled from 'styled-components'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
containsOrEqualsElement,
|
|
6
|
+
focusFirstDescendant,
|
|
7
|
+
focusLastDescendant,
|
|
8
|
+
isHTMLElement,
|
|
9
|
+
} from '../../helpers'
|
|
5
10
|
import {useArrayProp, useClickOutside, useForwardedRef, useGlobalKeyDown} from '../../hooks'
|
|
6
11
|
import {Box, Button, Card, Container, Flex, Text} from '../../primitives'
|
|
7
12
|
import {ResponsivePaddingProps, ResponsiveWidthProps} from '../../primitives/types'
|
|
8
13
|
import {responsivePaddingStyle, ResponsivePaddingStyleProps} from '../../styles/internal'
|
|
9
14
|
import {ThemeColorSchemeKey, useTheme} from '../../theme'
|
|
10
15
|
import {DialogPosition} from '../../types'
|
|
11
|
-
import {Layer, Portal, useLayer} from '../../utils'
|
|
16
|
+
import {Layer, LayerProps, Portal, useBoundaryElement, useLayer, usePortal} from '../../utils'
|
|
12
17
|
import {
|
|
13
18
|
dialogStyle,
|
|
14
19
|
responsiveDialogPositionStyle,
|
|
@@ -34,6 +39,8 @@ export interface DialogProps extends ResponsivePaddingProps, ResponsiveWidthProp
|
|
|
34
39
|
footer?: React.ReactNode
|
|
35
40
|
header?: React.ReactNode
|
|
36
41
|
id: string
|
|
42
|
+
/** A callback that fires when the dialog becomes the top layer when it was not the top layer before. */
|
|
43
|
+
onActivate?: LayerProps['onActivate']
|
|
37
44
|
onClickOutside?: () => void
|
|
38
45
|
onClose?: () => void
|
|
39
46
|
portal?: string
|
|
@@ -58,11 +65,25 @@ interface DialogCardProps extends ResponsiveWidthProps {
|
|
|
58
65
|
id: string
|
|
59
66
|
onClickOutside?: () => void
|
|
60
67
|
onClose?: () => void
|
|
68
|
+
portal?: string
|
|
61
69
|
radius: number | number[]
|
|
62
70
|
scheme?: ThemeColorSchemeKey
|
|
63
71
|
shadow: number | number[]
|
|
64
72
|
}
|
|
65
73
|
|
|
74
|
+
function isTargetWithinScope(
|
|
75
|
+
boundaryElement: HTMLElement | null,
|
|
76
|
+
portalElement: HTMLElement | null,
|
|
77
|
+
target: Node
|
|
78
|
+
): boolean {
|
|
79
|
+
if (!boundaryElement || !portalElement) return true
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
containsOrEqualsElement(boundaryElement, target) ||
|
|
83
|
+
containsOrEqualsElement(portalElement, target)
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
const Root = styled(Layer)<ResponsiveDialogPositionStyleProps & ResponsivePaddingStyleProps>(
|
|
67
88
|
responsivePaddingStyle,
|
|
68
89
|
dialogStyle,
|
|
@@ -138,11 +159,15 @@ const DialogCard = forwardRef(function DialogCard(
|
|
|
138
159
|
id,
|
|
139
160
|
onClickOutside,
|
|
140
161
|
onClose,
|
|
162
|
+
portal: portalProp,
|
|
141
163
|
radius: radiusProp,
|
|
142
164
|
scheme,
|
|
143
165
|
shadow: shadowProp,
|
|
144
166
|
width: widthProp,
|
|
145
167
|
} = props
|
|
168
|
+
const portal = usePortal()
|
|
169
|
+
const portalElement = portalProp ? portal.elements?.[portalProp] || null : portal.element
|
|
170
|
+
const boundaryElement = useBoundaryElement().element
|
|
146
171
|
const radius = useArrayProp(radiusProp)
|
|
147
172
|
const shadow = useArrayProp(shadowProp)
|
|
148
173
|
const width = useArrayProp(widthProp)
|
|
@@ -158,7 +183,7 @@ const DialogCard = forwardRef(function DialogCard(
|
|
|
158
183
|
useEffect(() => {
|
|
159
184
|
if (!autoFocus) return
|
|
160
185
|
|
|
161
|
-
// On mount: focus the first
|
|
186
|
+
// On mount: focus the first focusable element
|
|
162
187
|
if (forwardedRef.current) {
|
|
163
188
|
focusFirstDescendant(forwardedRef.current)
|
|
164
189
|
}
|
|
@@ -169,22 +194,39 @@ const DialogCard = forwardRef(function DialogCard(
|
|
|
169
194
|
(event: KeyboardEvent) => {
|
|
170
195
|
if (!isTopLayer || !onClose) return
|
|
171
196
|
|
|
197
|
+
const target = document.activeElement
|
|
198
|
+
|
|
199
|
+
if (target && !isTargetWithinScope(boundaryElement, portalElement, target)) {
|
|
200
|
+
// Ignore key presses when the focused element is outside of scope
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
172
204
|
if (event.key === 'Escape') {
|
|
173
205
|
event.preventDefault()
|
|
174
206
|
event.stopPropagation()
|
|
175
207
|
onClose()
|
|
176
208
|
}
|
|
177
209
|
},
|
|
178
|
-
[isTopLayer, onClose]
|
|
210
|
+
[boundaryElement, isTopLayer, onClose, portalElement]
|
|
179
211
|
)
|
|
180
212
|
)
|
|
181
213
|
|
|
182
214
|
useClickOutside(
|
|
183
|
-
useCallback(
|
|
184
|
-
|
|
215
|
+
useCallback(
|
|
216
|
+
(event: MouseEvent) => {
|
|
217
|
+
if (!isTopLayer || !onClickOutside) return
|
|
218
|
+
|
|
219
|
+
const target = event.target as Node | null
|
|
185
220
|
|
|
186
|
-
|
|
187
|
-
|
|
221
|
+
if (target && !isTargetWithinScope(boundaryElement, portalElement, target)) {
|
|
222
|
+
// Ignore clicks outside of the scope
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
onClickOutside()
|
|
227
|
+
},
|
|
228
|
+
[boundaryElement, isTopLayer, onClickOutside, portalElement]
|
|
229
|
+
),
|
|
188
230
|
[rootElement]
|
|
189
231
|
)
|
|
190
232
|
|
|
@@ -265,16 +307,21 @@ export const Dialog = forwardRef(function Dialog(
|
|
|
265
307
|
footer,
|
|
266
308
|
header,
|
|
267
309
|
id,
|
|
310
|
+
onActivate,
|
|
268
311
|
onClickOutside,
|
|
269
312
|
onClose,
|
|
313
|
+
onFocus,
|
|
270
314
|
padding: paddingProp = 4,
|
|
271
|
-
portal,
|
|
315
|
+
portal: portalProp,
|
|
272
316
|
position: positionProp = dialog.position || 'fixed',
|
|
273
317
|
scheme,
|
|
274
318
|
width: widthProp = 0,
|
|
275
319
|
zOffset: zOffsetProp = dialog.zOffset || theme.sanity.layer?.dialog.zOffset,
|
|
276
320
|
...restProps
|
|
277
321
|
} = props
|
|
322
|
+
const portal = usePortal()
|
|
323
|
+
const portalElement = portalProp ? portal.elements?.[portalProp] || null : portal.element
|
|
324
|
+
const boundaryElement = useBoundaryElement().element
|
|
278
325
|
const cardRadius = useArrayProp(cardRadiusProp)
|
|
279
326
|
const padding = useArrayProp(paddingProp)
|
|
280
327
|
const position = useArrayProp(positionProp)
|
|
@@ -283,32 +330,67 @@ export const Dialog = forwardRef(function Dialog(
|
|
|
283
330
|
const preDivRef = useRef<HTMLDivElement | null>(null)
|
|
284
331
|
const postDivRef = useRef<HTMLDivElement | null>(null)
|
|
285
332
|
const cardRef = useRef<HTMLDivElement | null>(null)
|
|
333
|
+
const focusedElementRef = useRef<HTMLElement | null>(null)
|
|
286
334
|
|
|
287
|
-
const handleFocus = useCallback(
|
|
288
|
-
|
|
289
|
-
|
|
335
|
+
const handleFocus = useCallback(
|
|
336
|
+
(event: React.FocusEvent<HTMLDivElement>) => {
|
|
337
|
+
onFocus?.(event)
|
|
290
338
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
339
|
+
const target = event.target
|
|
340
|
+
const cardElement = cardRef.current
|
|
294
341
|
|
|
295
|
-
|
|
296
|
-
|
|
342
|
+
if (cardElement && target === preDivRef.current) {
|
|
343
|
+
focusLastDescendant(cardElement)
|
|
297
344
|
|
|
298
|
-
|
|
299
|
-
|
|
345
|
+
return
|
|
346
|
+
}
|
|
300
347
|
|
|
301
|
-
|
|
302
|
-
|
|
348
|
+
if (cardElement && target === postDivRef.current) {
|
|
349
|
+
focusFirstDescendant(cardElement)
|
|
303
350
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (isHTMLElement(event.target)) {
|
|
355
|
+
focusedElementRef.current = event.target
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
[onFocus]
|
|
359
|
+
)
|
|
307
360
|
|
|
308
361
|
const labelId = `${id}_label`
|
|
309
362
|
|
|
363
|
+
const rootClickTimeoutRef = useRef<NodeJS.Timeout>()
|
|
364
|
+
|
|
365
|
+
// If the resulting active element (a.k.a. focused element) is not withing scope when clicking
|
|
366
|
+
// within the dialog, then we want to focus the previously interactive element in the dialog instead.
|
|
367
|
+
// This is to allow the user to tab or close the dialog by pressing escape.
|
|
368
|
+
const handleRootClick = useCallback(() => {
|
|
369
|
+
if (rootClickTimeoutRef.current) {
|
|
370
|
+
clearTimeout(rootClickTimeoutRef.current)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
rootClickTimeoutRef.current = setTimeout(() => {
|
|
374
|
+
const activeElement = document.activeElement
|
|
375
|
+
|
|
376
|
+
if (activeElement && !isTargetWithinScope(boundaryElement, portalElement, activeElement)) {
|
|
377
|
+
const target = focusedElementRef.current
|
|
378
|
+
|
|
379
|
+
if (!target || !document.body.contains(target)) {
|
|
380
|
+
// No previously focused element, or it's not in the document anymore
|
|
381
|
+
const cardElement = cardRef.current
|
|
382
|
+
if (cardElement) focusFirstDescendant(cardElement)
|
|
383
|
+
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
target.focus()
|
|
388
|
+
}
|
|
389
|
+
}, 0)
|
|
390
|
+
}, [boundaryElement, portalElement])
|
|
391
|
+
|
|
310
392
|
return (
|
|
311
|
-
<Portal __unstable_name={
|
|
393
|
+
<Portal __unstable_name={portalProp}>
|
|
312
394
|
<Root
|
|
313
395
|
{...restProps}
|
|
314
396
|
$padding={padding}
|
|
@@ -317,6 +399,8 @@ export const Dialog = forwardRef(function Dialog(
|
|
|
317
399
|
aria-modal
|
|
318
400
|
data-ui="Dialog"
|
|
319
401
|
id={id}
|
|
402
|
+
onActivate={onActivate}
|
|
403
|
+
onClick={handleRootClick}
|
|
320
404
|
onFocus={handleFocus}
|
|
321
405
|
ref={ref}
|
|
322
406
|
role="dialog"
|
|
@@ -332,6 +416,7 @@ export const Dialog = forwardRef(function Dialog(
|
|
|
332
416
|
id={id}
|
|
333
417
|
onClickOutside={onClickOutside}
|
|
334
418
|
onClose={onClose}
|
|
419
|
+
portal={portalProp}
|
|
335
420
|
radius={cardRadius}
|
|
336
421
|
ref={cardRef}
|
|
337
422
|
scheme={scheme}
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
import {AnimatePresence, motion} from 'framer-motion'
|
|
2
|
-
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
|
2
|
+
import {useCallback, useEffect, useMemo, useRef, useState, startTransition} from 'react'
|
|
3
3
|
import styled from 'styled-components'
|
|
4
|
+
import {useMounted} from '../../hooks/useMounted'
|
|
4
5
|
import {Box} from '../../primitives'
|
|
5
6
|
import {Layer} from '../../utils'
|
|
6
7
|
import {Toast} from './toast'
|
|
7
8
|
import {ToastContext} from './toastContext'
|
|
8
9
|
import {ToastContextValue, ToastParams} from './types'
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}[]
|
|
16
|
-
}
|
|
11
|
+
type ToastState = {
|
|
12
|
+
dismiss: () => void
|
|
13
|
+
id: string
|
|
14
|
+
params: ToastParams
|
|
15
|
+
}[]
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* @public
|
|
@@ -51,25 +50,29 @@ let toastId = 0
|
|
|
51
50
|
*/
|
|
52
51
|
export function ToastProvider(props: ToastProviderProps): React.ReactElement {
|
|
53
52
|
const {children, padding = 4, paddingX, paddingY, zOffset} = props
|
|
54
|
-
const [state,
|
|
53
|
+
const [state, _setState] = useState<ToastState>([])
|
|
54
|
+
|
|
55
55
|
const toastsRef = useRef<{[key: string]: {timeoutId: NodeJS.Timeout}}>({})
|
|
56
56
|
|
|
57
57
|
const push = useCallback((params: ToastParams) => {
|
|
58
|
+
// Wrap setState in startTransition to allow React to give input state updates higher priority
|
|
59
|
+
const setState: typeof _setState = (state) => startTransition(() => _setState(state))
|
|
60
|
+
|
|
58
61
|
const id = params.id || String(toastId++)
|
|
59
62
|
const duration = params.duration || 5000
|
|
60
63
|
|
|
61
64
|
const dismiss = () => {
|
|
62
65
|
const timeoutId = toastsRef.current[id]?.timeoutId
|
|
63
66
|
|
|
64
|
-
setState((prevState) => {
|
|
65
|
-
const idx = prevState.
|
|
67
|
+
setState((prevState): ToastState => {
|
|
68
|
+
const idx = prevState.findIndex((t) => t.id === id)
|
|
66
69
|
|
|
67
70
|
if (idx > -1) {
|
|
68
|
-
const toasts = prevState.
|
|
71
|
+
const toasts = prevState.slice(0)
|
|
69
72
|
|
|
70
73
|
toasts.splice(idx, 1)
|
|
71
74
|
|
|
72
|
-
return
|
|
75
|
+
return toasts
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
return prevState
|
|
@@ -81,19 +84,16 @@ export function ToastProvider(props: ToastProviderProps): React.ReactElement {
|
|
|
81
84
|
}
|
|
82
85
|
}
|
|
83
86
|
|
|
84
|
-
setState((prevState) => {
|
|
85
|
-
return
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
},
|
|
95
|
-
]),
|
|
96
|
-
}
|
|
87
|
+
setState((prevState): ToastState => {
|
|
88
|
+
return prevState
|
|
89
|
+
.filter((t) => t.id !== id)
|
|
90
|
+
.concat([
|
|
91
|
+
{
|
|
92
|
+
dismiss,
|
|
93
|
+
id,
|
|
94
|
+
params: {...params, duration},
|
|
95
|
+
},
|
|
96
|
+
])
|
|
97
97
|
})
|
|
98
98
|
|
|
99
99
|
if (toastsRef.current[id]) {
|
|
@@ -119,37 +119,40 @@ export function ToastProvider(props: ToastProviderProps): React.ReactElement {
|
|
|
119
119
|
)
|
|
120
120
|
|
|
121
121
|
const value: ToastContextValue = useMemo(() => ({version: 0.0, push}), [push])
|
|
122
|
+
const mounted = useMounted()
|
|
122
123
|
|
|
123
124
|
return (
|
|
124
125
|
<ToastContext.Provider value={value}>
|
|
125
126
|
{children}
|
|
126
127
|
|
|
127
|
-
|
|
128
|
-
<
|
|
129
|
-
<
|
|
130
|
-
<
|
|
131
|
-
{
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
128
|
+
{mounted && (
|
|
129
|
+
<Root data-ui="ToastProvider" zOffset={zOffset}>
|
|
130
|
+
<ToastContainer>
|
|
131
|
+
<Box padding={padding} paddingX={paddingX} paddingY={paddingY}>
|
|
132
|
+
<AnimatePresence initial={false}>
|
|
133
|
+
{state.map(({dismiss, id, params}) => (
|
|
134
|
+
<motion.div
|
|
135
|
+
animate={{opacity: 1, y: 0, scale: 1}}
|
|
136
|
+
exit={{opacity: 0, scale: 0.5, transition: {duration: 0.2}}}
|
|
137
|
+
initial={{opacity: 0, y: 32, scale: 0.25}}
|
|
138
|
+
key={id}
|
|
139
|
+
layout="position"
|
|
140
|
+
transition={{type: 'spring', damping: 30, stiffness: 400}}
|
|
141
|
+
>
|
|
142
|
+
<Toast
|
|
143
|
+
closable={params.closable}
|
|
144
|
+
description={params.description}
|
|
145
|
+
onClose={dismiss}
|
|
146
|
+
status={params.status}
|
|
147
|
+
title={params.title}
|
|
148
|
+
/>
|
|
149
|
+
</motion.div>
|
|
150
|
+
))}
|
|
151
|
+
</AnimatePresence>
|
|
152
|
+
</Box>
|
|
153
|
+
</ToastContainer>
|
|
154
|
+
</Root>
|
|
155
|
+
)}
|
|
153
156
|
</ToastContext.Provider>
|
|
154
157
|
)
|
|
155
158
|
}
|
package/src/constants.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @internal
|
|
3
3
|
*/
|
|
4
|
-
export const EMPTY_ARRAY:
|
|
4
|
+
export const EMPTY_ARRAY: never[] = []
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* @internal
|
|
8
8
|
*/
|
|
9
|
-
export const EMPTY_RECORD: Record<string,
|
|
9
|
+
export const EMPTY_RECORD: Record<string, never> = {}
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* @internal
|
package/src/helpers/element.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -12,7 +12,8 @@ export function useArrayProp<T extends ArrayPropPrimitive = ArrayPropPrimitive>(
|
|
|
12
12
|
val: T | T[] | undefined,
|
|
13
13
|
defaultVal?: T[]
|
|
14
14
|
): T[] {
|
|
15
|
-
|
|
15
|
+
// JSON.stringify is fast, but it's not faster than useMemo's referencial equality check
|
|
16
|
+
const __perf_hash__ = useMemo(() => JSON.stringify(val ?? defaultVal), [defaultVal, val])
|
|
16
17
|
|
|
17
18
|
return useMemo(
|
|
18
19
|
() => _getArrayProp(val, defaultVal),
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
import {renderToString, renderToStaticMarkup} from 'react-dom/server'
|
|
3
|
+
import {studioTheme, ThemeProvider} from '../../theme'
|
|
4
|
+
import {useMediaIndex} from './useMediaIndex'
|
|
5
|
+
|
|
6
|
+
function Log() {
|
|
7
|
+
const mediaIndex = useMediaIndex()
|
|
8
|
+
|
|
9
|
+
return <>mediaIndex: {JSON.stringify(mediaIndex)}</>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('useMediaIndex', () => {
|
|
13
|
+
it(`SSR to static markup returns 0`, () => {
|
|
14
|
+
expect(
|
|
15
|
+
renderToStaticMarkup(
|
|
16
|
+
<ThemeProvider theme={studioTheme}>
|
|
17
|
+
<Log />
|
|
18
|
+
</ThemeProvider>
|
|
19
|
+
)
|
|
20
|
+
).toBe('mediaIndex: 0')
|
|
21
|
+
})
|
|
22
|
+
it(`SSR to markup for hydration doesn't throw`, () => {
|
|
23
|
+
expect(
|
|
24
|
+
renderToString(
|
|
25
|
+
<ThemeProvider theme={studioTheme}>
|
|
26
|
+
<Log />
|
|
27
|
+
</ThemeProvider>
|
|
28
|
+
)
|
|
29
|
+
).toMatchInlineSnapshot(`"mediaIndex: <!-- -->0"`)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -1,6 +1,95 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {useSyncExternalStore} from 'react'
|
|
2
2
|
import {useTheme} from '../../theme'
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
export interface _MediaStore {
|
|
8
|
+
subscribe: (onStoreChange: () => void) => () => void
|
|
9
|
+
getSnapshot: () => number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MEDIA_STORE_CACHE = new WeakMap<number[], _MediaStore>()
|
|
13
|
+
|
|
14
|
+
type MediaQueryMinWidth = `(min-width: ${number}px)`
|
|
15
|
+
type MediaQueryMaxWidth = `(max-width: ${number}px)`
|
|
16
|
+
type MediaQueryMinMaxWidth = `${MediaQueryMinWidth} and ${MediaQueryMaxWidth}`
|
|
17
|
+
type MediaQuery = `screen and ${MediaQueryMinWidth | MediaQueryMaxWidth | MediaQueryMinMaxWidth}`
|
|
18
|
+
|
|
19
|
+
function _getMediaQuery(media: number[], index: number): MediaQuery {
|
|
20
|
+
if (index === 0) {
|
|
21
|
+
return `screen and (max-width: ${media[index] - 1}px)`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (index === media.length) {
|
|
25
|
+
return `screen and (min-width: ${media[index - 1]}px)`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return `screen and (min-width: ${media[index - 1]}px) and (max-width: ${media[index] - 1}px)`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function _createMediaStore(media: number[]): _MediaStore {
|
|
32
|
+
const mediaLen = media.length
|
|
33
|
+
let sizes: {mq: MediaQueryList; index: number}[]
|
|
34
|
+
|
|
35
|
+
// The _createMediaStore function is called in both server and client environments.
|
|
36
|
+
// However since subscribe and getSnapshot are only called on the client we lazy init what we need for them
|
|
37
|
+
// so that we don't need to run checks for wether it's safe to call `window.matchMedia`
|
|
38
|
+
const getSizes = () => {
|
|
39
|
+
if (!sizes) {
|
|
40
|
+
sizes = []
|
|
41
|
+
|
|
42
|
+
for (let index = mediaLen; index > -1; index -= 1) {
|
|
43
|
+
const mediaQuery = _getMediaQuery(media, index)
|
|
44
|
+
|
|
45
|
+
sizes.push({index, mq: window.matchMedia(mediaQuery)})
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return sizes
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const getSnapshot = () => {
|
|
53
|
+
for (const {index, mq} of getSizes()) {
|
|
54
|
+
if (mq.matches) return index
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const subscribe = (onStoreChange: () => void) => {
|
|
61
|
+
const disposeFns: (() => void)[] = []
|
|
62
|
+
|
|
63
|
+
for (const {mq} of getSizes()) {
|
|
64
|
+
const handleChange = () => {
|
|
65
|
+
if (mq.matches) onStoreChange()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
mq.addEventListener('change', handleChange)
|
|
69
|
+
|
|
70
|
+
disposeFns.push(() => mq.removeEventListener('change', handleChange))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return () => {
|
|
74
|
+
for (const disposeFn of disposeFns) {
|
|
75
|
+
disposeFn()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {getSnapshot, subscribe}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Only called during server-side rendering, and hydration if using hydrateRoot
|
|
85
|
+
* Since the server environment doesn't have access to the DOM, we can't determine the current value of the media query
|
|
86
|
+
* and we assume `(prefers-color-scheme: light)` since it's the most common scheme
|
|
87
|
+
*
|
|
88
|
+
* @link https://beta.reactjs.org/apis/react/useSyncExternalStore#adding-support-for-server-rendering
|
|
89
|
+
*/
|
|
90
|
+
function getServerSnapshot() {
|
|
91
|
+
return 0
|
|
92
|
+
}
|
|
4
93
|
|
|
5
94
|
/**
|
|
6
95
|
* This API might change. DO NOT USE IN PRODUCTION.
|
|
@@ -9,10 +98,13 @@ import {_getMediaManager} from './lib/media'
|
|
|
9
98
|
export function useMediaIndex(): number {
|
|
10
99
|
const theme = useTheme()
|
|
11
100
|
const {media} = theme.sanity
|
|
12
|
-
const manager = useMemo(() => _getMediaManager(media), [media])
|
|
13
|
-
const [index, setIndex] = useState(manager.getCurrentIndex)
|
|
14
101
|
|
|
15
|
-
|
|
102
|
+
let store = MEDIA_STORE_CACHE.get(media)
|
|
103
|
+
|
|
104
|
+
if (!store) {
|
|
105
|
+
store = _createMediaStore(media)
|
|
106
|
+
MEDIA_STORE_CACHE.set(media, store)
|
|
107
|
+
}
|
|
16
108
|
|
|
17
|
-
return
|
|
109
|
+
return useSyncExternalStore(store.subscribe, store.getSnapshot, getServerSnapshot)
|
|
18
110
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {useEffect, useReducer} from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Some components should only render after mounting to the DOM, and not be rendered at all during SSR renderToString or equivalent.
|
|
5
|
+
* @public
|
|
6
|
+
*/
|
|
7
|
+
export function useMounted(): boolean {
|
|
8
|
+
// Use useReducer instead of useState as it's more low-level and creates the least amount of functions for the garbage collector to clean up
|
|
9
|
+
const [mounted, mount] = useReducer(() => true, false)
|
|
10
|
+
|
|
11
|
+
useEffect(mount, [mount])
|
|
12
|
+
|
|
13
|
+
return mounted
|
|
14
|
+
}
|
|
@@ -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 {usePrefersDark} from './usePrefersDark'
|
|
10
|
+
|
|
11
|
+
function Log() {
|
|
12
|
+
const dark = usePrefersDark()
|
|
13
|
+
|
|
14
|
+
return <>dark: {JSON.stringify(dark)}</>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const originalMatchMedia = window.matchMedia
|
|
18
|
+
|
|
19
|
+
describe('usePrefersDark 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 = `dark: <!-- -->false`
|
|
41
|
+
|
|
42
|
+
hydrateRoot(node, <Log />)
|
|
43
|
+
|
|
44
|
+
// It's false initially
|
|
45
|
+
await waitFor(() => expect(node.innerHTML).toBe('dark: <!-- -->false'))
|
|
46
|
+
|
|
47
|
+
// After hydration it should switch to true
|
|
48
|
+
await waitFor(() => expect(node.innerHTML).toBe('dark: <!-- -->true'))
|
|
49
|
+
|
|
50
|
+
// eslint-disable-next-line no-console
|
|
51
|
+
expect(console.error).not.toHaveBeenCalled()
|
|
52
|
+
|
|
53
|
+
spy.mockReset()
|
|
54
|
+
spy.mockRestore()
|
|
55
|
+
})
|
|
56
|
+
})
|