@chronogrove/ui 0.79.0 → 0.81.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/README.md +40 -19
- package/package.json +73 -6
- package/src/__snapshots__/header.spec.js.snap +8 -8
- package/src/__snapshots__/theme.spec.js.snap +39 -20
- package/src/action-button.js +6 -6
- package/src/action-button.spec.js +14 -2
- package/src/action-card-layout.js +13 -0
- package/src/action-card-layout.spec.js +13 -0
- package/src/animated-page-background/ChronogroveAnimatedPageBackground.js +153 -0
- package/src/animated-page-background/ChronogroveAnimatedPageBackground.spec.js +189 -0
- package/src/animated-page-background/ColorBends.js +309 -0
- package/src/animated-page-background/color-bends.css +13 -0
- package/src/animated-page-background/index.js +2 -0
- package/src/animated-page-background/index.spec.js +18 -0
- package/src/button.js +4 -3
- package/src/category-label.js +23 -0
- package/src/category-label.spec.js +24 -0
- package/src/chevron-icons.js +37 -0
- package/src/chronogrove-theme-surface-colors.js +22 -0
- package/src/color-mode/browser-sync.js +7 -0
- package/src/color-mode/browser-sync.spec.js +7 -0
- package/src/color-mode/chronogrove-head-theme.js +22 -0
- package/src/color-mode/head-inline.js +40 -5
- package/src/color-mode/head-inline.spec.js +29 -0
- package/src/color-mode/index.js +3 -0
- package/src/color-mode/resolve-theme-colors.js +18 -6
- package/src/color-mode/resolve-theme-colors.spec.js +13 -3
- package/src/color-mode/spa-navigation.js +14 -0
- package/src/color-mode/spa-navigation.spec.js +25 -0
- package/src/color-mode/use-document-color-mode-surface.js +52 -0
- package/src/color-mode/use-document-color-mode-surface.node.spec.js +12 -0
- package/src/color-mode/use-document-color-mode-surface.spec.js +154 -0
- package/src/color-toggle-styles.css +10 -0
- package/src/color-toggle.js +11 -2
- package/src/emotion-cache.node.spec.js +13 -0
- package/src/emotion-cache.spec.js +12 -0
- package/src/external-link-icon.js +30 -0
- package/src/external-link-icon.spec.js +16 -0
- package/src/gatsby/build-theme-ui-color-mode-head-components.js +7 -1
- package/src/gatsby/index.spec.js +42 -0
- package/src/gatsby/on-route-update-color-mode.js +1 -14
- package/src/header.js +4 -16
- package/src/lazy-load.js +30 -11
- package/src/lazy-load.spec.js +9 -5
- package/src/metric-badge.js +10 -0
- package/src/metric-badge.spec.js +15 -0
- package/src/metric-card.js +95 -0
- package/src/metric-card.spec.js +60 -0
- package/src/muted-card-footer.js +22 -0
- package/src/muted-card-footer.spec.js +25 -0
- package/src/next/app-shell.js +34 -0
- package/src/next/emotion-registry.js +68 -0
- package/src/next/emotion-registry.spec.js +99 -0
- package/src/next/index.js +4 -0
- package/src/next/root-layout-head.js +42 -0
- package/src/next/root-layout-head.spec.js +17 -0
- package/src/next/theme-ui-color-mode-route-sync.js +32 -0
- package/src/page-backdrop.js +42 -0
- package/src/page-backdrop.spec.js +41 -0
- package/src/pagination-button.js +4 -4
- package/src/pagination-button.spec.js +26 -2
- package/src/pagination.js +198 -0
- package/src/pagination.spec.js +281 -0
- package/src/skip-nav/SkipNavLink.js +6 -5
- package/src/skip-nav/SkipNavLink.spec.js +11 -0
- package/src/status-card.js +18 -0
- package/src/status-card.spec.js +38 -0
- package/src/theme.js +27 -20
- package/src/widget-call-to-action.js +106 -0
- package/src/widget-call-to-action.spec.js +115 -0
- package/src/widget-section.js +83 -0
- package/src/widget-section.spec.js +59 -0
package/src/gatsby/index.spec.js
CHANGED
|
@@ -40,6 +40,8 @@ describe('@chronogrove/ui/gatsby', () => {
|
|
|
40
40
|
const fallbackStyle = fallbackStyleContainer.querySelector('style')
|
|
41
41
|
expect(fallbackStyle).toHaveTextContent(/:root\[data-theme-ui-color-mode="default"\]/)
|
|
42
42
|
expect(fallbackStyle).toHaveTextContent(/--theme-ui-colors-text: #111 !important/)
|
|
43
|
+
expect(fallbackStyle).toHaveTextContent(/--theme-ui-colors-panel-background:/)
|
|
44
|
+
expect(fallbackStyle).toHaveTextContent(/--theme-ui-colors-panel-text:/)
|
|
43
45
|
})
|
|
44
46
|
})
|
|
45
47
|
|
|
@@ -100,6 +102,46 @@ describe('@chronogrove/ui/gatsby', () => {
|
|
|
100
102
|
onPreRenderHTMLSortThemeUiColorModeFirst({ getHeadComponents, replaceHeadComponents })
|
|
101
103
|
expect(replaceHeadComponents.mock.calls[0][0].map(c => c.key)).toEqual(['html-bg-color', 'theme-ui-no-flash'])
|
|
102
104
|
})
|
|
105
|
+
|
|
106
|
+
it('treats missing component keys as empty string', () => {
|
|
107
|
+
const getHeadComponents = jest.fn(() => [{ type: 'meta' }, { key: 'theme-ui-no-flash', type: 'script' }])
|
|
108
|
+
const replaceHeadComponents = jest.fn()
|
|
109
|
+
onPreRenderHTMLSortThemeUiColorModeFirst({ getHeadComponents, replaceHeadComponents })
|
|
110
|
+
const sorted = replaceHeadComponents.mock.calls[0][0]
|
|
111
|
+
expect(sorted[0].key).toBe('theme-ui-no-flash')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('handles null or undefined head entries when sorting', () => {
|
|
115
|
+
const getHeadComponents = jest.fn(() => [
|
|
116
|
+
null,
|
|
117
|
+
undefined,
|
|
118
|
+
{ key: 'theme-ui-no-flash', type: 'script' },
|
|
119
|
+
{ key: 'other', type: 'meta' }
|
|
120
|
+
])
|
|
121
|
+
const replaceHeadComponents = jest.fn()
|
|
122
|
+
onPreRenderHTMLSortThemeUiColorModeFirst({ getHeadComponents, replaceHeadComponents })
|
|
123
|
+
const sorted = replaceHeadComponents.mock.calls[0][0]
|
|
124
|
+
expect(sorted[0].key).toBe('theme-ui-no-flash')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('treats explicit null keys like missing keys', () => {
|
|
128
|
+
const getHeadComponents = jest.fn(() => [
|
|
129
|
+
{ key: null, type: 'meta' },
|
|
130
|
+
{ key: 'theme-ui-no-flash', type: 'script' }
|
|
131
|
+
])
|
|
132
|
+
const replaceHeadComponents = jest.fn()
|
|
133
|
+
onPreRenderHTMLSortThemeUiColorModeFirst({ getHeadComponents, replaceHeadComponents })
|
|
134
|
+
const sorted = replaceHeadComponents.mock.calls[0][0]
|
|
135
|
+
expect(sorted[0].key).toBe('theme-ui-no-flash')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('sorts when the right-hand entry is null', () => {
|
|
139
|
+
const getHeadComponents = jest.fn(() => [{ key: 'theme-ui-no-flash', type: 'script' }, null])
|
|
140
|
+
const replaceHeadComponents = jest.fn()
|
|
141
|
+
onPreRenderHTMLSortThemeUiColorModeFirst({ getHeadComponents, replaceHeadComponents })
|
|
142
|
+
const sorted = replaceHeadComponents.mock.calls[0][0]
|
|
143
|
+
expect(sorted[0].key).toBe('theme-ui-no-flash')
|
|
144
|
+
})
|
|
103
145
|
})
|
|
104
146
|
|
|
105
147
|
describe('onRouteUpdateThemeUiColorMode', () => {
|
|
@@ -1,14 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { scheduleThemeUiColorModeSync } from '../color-mode/browser-sync.js'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Call from Gatsby `onRouteUpdate` so Theme UI color mode stays aligned with `localStorage` and the
|
|
6
|
-
* document after client-side navigations. Dispatches {@link RECONCILE_COLOR_MODE_EVENT} for app code
|
|
7
|
-
* that listens (e.g. React context reconciliation).
|
|
8
|
-
*/
|
|
9
|
-
export function onRouteUpdateThemeUiColorMode() {
|
|
10
|
-
scheduleThemeUiColorModeSync()
|
|
11
|
-
if (typeof window !== 'undefined' && typeof window.CustomEvent === 'function') {
|
|
12
|
-
window.dispatchEvent(new window.CustomEvent(RECONCILE_COLOR_MODE_EVENT))
|
|
13
|
-
}
|
|
14
|
-
}
|
|
1
|
+
export { reconcileThemeUiColorModeOnNavigation as onRouteUpdateThemeUiColorMode } from '../color-mode/spa-navigation.js'
|
package/src/header.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { jsx } from 'theme-ui'
|
|
1
|
+
import { Box } from '@theme-ui/components'
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* Header
|
|
@@ -8,20 +7,9 @@ import { jsx } from 'theme-ui'
|
|
|
8
7
|
*/
|
|
9
8
|
const Header = ({ children, styles }) => {
|
|
10
9
|
return (
|
|
11
|
-
<header
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
variant: 'styles.Header'
|
|
15
|
-
}}
|
|
16
|
-
>
|
|
17
|
-
<div
|
|
18
|
-
sx={{
|
|
19
|
-
...(styles ? styles : {})
|
|
20
|
-
}}
|
|
21
|
-
>
|
|
22
|
-
{children}
|
|
23
|
-
</div>
|
|
24
|
-
</header>
|
|
10
|
+
<Box as='header' role='banner' sx={{ variant: 'styles.Header' }}>
|
|
11
|
+
<Box sx={{ ...(styles ? styles : {}) }}>{children}</Box>
|
|
12
|
+
</Box>
|
|
25
13
|
)
|
|
26
14
|
}
|
|
27
15
|
|
package/src/lazy-load.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
/** @jsx jsx */
|
|
2
|
-
import { jsx } from 'theme-ui'
|
|
3
1
|
import { useState, useEffect } from 'react'
|
|
2
|
+
import { Box } from '@theme-ui/components'
|
|
4
3
|
import { useInView } from 'react-intersection-observer'
|
|
5
4
|
|
|
6
5
|
const DefaultPlaceholder = ({ height = '100%', width = '100%' }) => (
|
|
7
|
-
<
|
|
6
|
+
<Box
|
|
8
7
|
data-testid='default-placeholder'
|
|
9
8
|
sx={{
|
|
10
9
|
minHeight: '1px',
|
|
@@ -14,28 +13,48 @@ const DefaultPlaceholder = ({ height = '100%', width = '100%' }) => (
|
|
|
14
13
|
}}
|
|
15
14
|
>
|
|
16
15
|
{' '}
|
|
17
|
-
</
|
|
16
|
+
</Box>
|
|
18
17
|
)
|
|
19
18
|
|
|
20
19
|
/**
|
|
21
20
|
* Lazy Loader
|
|
22
21
|
*
|
|
23
22
|
* Hides a component until it's been visible in the viewport.
|
|
23
|
+
*
|
|
24
|
+
* IntersectionObserver does not run on the server; children are never included in SSR HTML.
|
|
25
|
+
* On the client, observation is deferred until after mount so Next.js (and other SSR
|
|
26
|
+
* frameworks) hydrate the placeholder first, then attach the observer — avoiding a
|
|
27
|
+
* first-paint flash of real content when the block is already in view.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} [props.useInViewOptions] -
|
|
30
|
+
* Passed to `useInView` after defaults (`triggerOnce: true`, `threshold: 0`,
|
|
31
|
+
* `initialInView: false`). Use `rootMargin` or `threshold` for stricter visibility.
|
|
32
|
+
* Pass `skip` to disable observation (merged with the internal client-only `skip`).
|
|
24
33
|
*/
|
|
25
|
-
const LazyLoad = ({ children, placeholder = <DefaultPlaceholder
|
|
26
|
-
const [
|
|
34
|
+
const LazyLoad = ({ children, placeholder = <DefaultPlaceholder />, useInViewOptions = {} }) => {
|
|
35
|
+
const [mounted, setMounted] = useState(false)
|
|
36
|
+
const [revealed, setRevealed] = useState(false)
|
|
37
|
+
const { skip: skipFromOptions, ...restInViewOptions } = useInViewOptions
|
|
38
|
+
|
|
27
39
|
const { ref, inView } = useInView({
|
|
28
40
|
triggerOnce: true,
|
|
29
|
-
threshold: 0
|
|
41
|
+
threshold: 0,
|
|
42
|
+
initialInView: false,
|
|
43
|
+
...restInViewOptions,
|
|
44
|
+
skip: !mounted || !!skipFromOptions
|
|
30
45
|
})
|
|
31
46
|
|
|
32
47
|
useEffect(() => {
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
setMounted(true)
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (mounted && inView) {
|
|
53
|
+
setRevealed(true)
|
|
35
54
|
}
|
|
36
|
-
}, [
|
|
55
|
+
}, [mounted, inView])
|
|
37
56
|
|
|
38
|
-
return <
|
|
57
|
+
return <Box ref={ref}>{revealed ? children : placeholder}</Box>
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
export default LazyLoad
|
package/src/lazy-load.spec.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
3
3
|
import '@testing-library/jest-dom'
|
|
4
4
|
import LazyLoad from './lazy-load.js'
|
|
5
5
|
|
|
@@ -31,7 +31,7 @@ describe('LazyLoad', () => {
|
|
|
31
31
|
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
|
|
32
32
|
})
|
|
33
33
|
|
|
34
|
-
it('renders the children when visible', () => {
|
|
34
|
+
it('renders the children when visible', async () => {
|
|
35
35
|
mockInView = true
|
|
36
36
|
|
|
37
37
|
render(
|
|
@@ -40,7 +40,9 @@ describe('LazyLoad', () => {
|
|
|
40
40
|
</LazyLoad>
|
|
41
41
|
)
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
await waitFor(() => {
|
|
44
|
+
expect(screen.getByTestId('content')).toBeInTheDocument()
|
|
45
|
+
})
|
|
44
46
|
})
|
|
45
47
|
|
|
46
48
|
it('renders the custom placeholder when provided', () => {
|
|
@@ -54,7 +56,7 @@ describe('LazyLoad', () => {
|
|
|
54
56
|
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
|
|
55
57
|
})
|
|
56
58
|
|
|
57
|
-
it('does not re-render children if already visible', () => {
|
|
59
|
+
it('does not re-render children if already visible', async () => {
|
|
58
60
|
mockInView = true
|
|
59
61
|
|
|
60
62
|
const { rerender } = render(
|
|
@@ -63,7 +65,9 @@ describe('LazyLoad', () => {
|
|
|
63
65
|
</LazyLoad>
|
|
64
66
|
)
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
await waitFor(() => {
|
|
69
|
+
expect(screen.getByTestId('content')).toBeInTheDocument()
|
|
70
|
+
})
|
|
67
71
|
|
|
68
72
|
mockInView = false
|
|
69
73
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { ThemeUIProvider } from 'theme-ui'
|
|
3
|
+
|
|
4
|
+
import MetricBadge from './metric-badge.js'
|
|
5
|
+
|
|
6
|
+
describe('MetricBadge', () => {
|
|
7
|
+
it('renders children', () => {
|
|
8
|
+
render(
|
|
9
|
+
<ThemeUIProvider theme={{}}>
|
|
10
|
+
<MetricBadge>42</MetricBadge>
|
|
11
|
+
</ThemeUIProvider>
|
|
12
|
+
)
|
|
13
|
+
expect(screen.getByText('42')).toBeInTheDocument()
|
|
14
|
+
})
|
|
15
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, Card, Text } from '@theme-ui/components'
|
|
3
|
+
import { useThemeUI } from 'theme-ui'
|
|
4
|
+
|
|
5
|
+
import isDarkMode from './helpers/isDarkMode.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Metric summary card (e.g. Goodreads profile metrics). Uses `metricCard` / `metricCardDark` card variants.
|
|
9
|
+
* When `loading` is true, renders `loadingSlot` or a lightweight pulse placeholder (no react-placeholder dependency).
|
|
10
|
+
*/
|
|
11
|
+
const MetricCard = ({ title, value, loading = false, loadingSlot, sx, ...props }) => {
|
|
12
|
+
const { colorMode } = useThemeUI()
|
|
13
|
+
const variant = isDarkMode(colorMode) ? 'metricCardDark' : 'metricCard'
|
|
14
|
+
|
|
15
|
+
const body = loading ? (
|
|
16
|
+
(loadingSlot ?? (
|
|
17
|
+
<Box
|
|
18
|
+
aria-busy='true'
|
|
19
|
+
role='status'
|
|
20
|
+
sx={{
|
|
21
|
+
minHeight: '3rem',
|
|
22
|
+
borderRadius: 'default',
|
|
23
|
+
bg: 'muted',
|
|
24
|
+
opacity: 0.7,
|
|
25
|
+
animation: 'cgPulse 1.2s ease-in-out infinite',
|
|
26
|
+
'@keyframes cgPulse': {
|
|
27
|
+
'0%, 100%': { opacity: 0.45 },
|
|
28
|
+
'50%': { opacity: 0.85 }
|
|
29
|
+
}
|
|
30
|
+
}}
|
|
31
|
+
/>
|
|
32
|
+
))
|
|
33
|
+
) : (
|
|
34
|
+
<Box
|
|
35
|
+
sx={{
|
|
36
|
+
display: 'flex',
|
|
37
|
+
flexDirection: 'column',
|
|
38
|
+
gap: 1,
|
|
39
|
+
alignItems: 'center',
|
|
40
|
+
justifyContent: 'center',
|
|
41
|
+
textAlign: 'center',
|
|
42
|
+
minWidth: 0,
|
|
43
|
+
width: '100%'
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
<Text
|
|
47
|
+
as='span'
|
|
48
|
+
sx={{
|
|
49
|
+
fontFamily: 'heading',
|
|
50
|
+
fontWeight: 'bold',
|
|
51
|
+
fontSize: [4, 5],
|
|
52
|
+
lineHeight: 1.1,
|
|
53
|
+
color: 'text',
|
|
54
|
+
m: 0,
|
|
55
|
+
letterSpacing: '-0.02em'
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{value}
|
|
59
|
+
</Text>
|
|
60
|
+
<Text
|
|
61
|
+
as='span'
|
|
62
|
+
sx={{
|
|
63
|
+
fontSize: 0,
|
|
64
|
+
color: 'textMuted',
|
|
65
|
+
lineHeight: 1.35,
|
|
66
|
+
m: 0,
|
|
67
|
+
textTransform: 'uppercase',
|
|
68
|
+
letterSpacing: '0.06em',
|
|
69
|
+
fontWeight: 'medium'
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{title}
|
|
73
|
+
</Text>
|
|
74
|
+
</Box>
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<Card
|
|
79
|
+
variant={variant}
|
|
80
|
+
sx={{
|
|
81
|
+
display: 'flex',
|
|
82
|
+
alignItems: 'stretch',
|
|
83
|
+
justifyContent: 'center',
|
|
84
|
+
minHeight: '6rem',
|
|
85
|
+
py: 3,
|
|
86
|
+
...sx
|
|
87
|
+
}}
|
|
88
|
+
{...props}
|
|
89
|
+
>
|
|
90
|
+
{body}
|
|
91
|
+
</Card>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default MetricCard
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { ThemeUIProvider } from 'theme-ui'
|
|
3
|
+
|
|
4
|
+
import MetricCard from './metric-card.js'
|
|
5
|
+
|
|
6
|
+
jest.mock('theme-ui', () => {
|
|
7
|
+
const actual = jest.requireActual('theme-ui')
|
|
8
|
+
return {
|
|
9
|
+
...actual,
|
|
10
|
+
useThemeUI: jest.fn(() => ({ colorMode: 'default' }))
|
|
11
|
+
}
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const { useThemeUI } = require('theme-ui')
|
|
15
|
+
|
|
16
|
+
const theme = { colors: { modes: { dark: {} } } }
|
|
17
|
+
|
|
18
|
+
describe('MetricCard', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
useThemeUI.mockReturnValue({ colorMode: 'default' })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('renders value and title when not loading', () => {
|
|
24
|
+
render(
|
|
25
|
+
<ThemeUIProvider theme={theme}>
|
|
26
|
+
<MetricCard title='Followers' value='12' />
|
|
27
|
+
</ThemeUIProvider>
|
|
28
|
+
)
|
|
29
|
+
expect(screen.getByText('12')).toBeInTheDocument()
|
|
30
|
+
expect(screen.getByText('Followers')).toBeInTheDocument()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('shows default loading placeholder when loading', () => {
|
|
34
|
+
render(
|
|
35
|
+
<ThemeUIProvider theme={theme}>
|
|
36
|
+
<MetricCard title='F' value='1' loading />
|
|
37
|
+
</ThemeUIProvider>
|
|
38
|
+
)
|
|
39
|
+
expect(screen.getByRole('status')).toBeInTheDocument()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('uses metricCardDark when dark', () => {
|
|
43
|
+
useThemeUI.mockReturnValue({ colorMode: 'dark' })
|
|
44
|
+
render(
|
|
45
|
+
<ThemeUIProvider theme={theme}>
|
|
46
|
+
<MetricCard title='T' value='v' loading={false} />
|
|
47
|
+
</ThemeUIProvider>
|
|
48
|
+
)
|
|
49
|
+
expect(screen.getByText('v')).toBeInTheDocument()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('uses custom loadingSlot', () => {
|
|
53
|
+
render(
|
|
54
|
+
<ThemeUIProvider theme={theme}>
|
|
55
|
+
<MetricCard title='t' value='v' loading loadingSlot={<span data-testid='slot'>wait</span>} />
|
|
56
|
+
</ThemeUIProvider>
|
|
57
|
+
)
|
|
58
|
+
expect(screen.getByTestId('slot')).toBeInTheDocument()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box } from '@theme-ui/components'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Muted footer row for dashboard-style cards. Uses `styles.mutedCardFooter` on the theme.
|
|
6
|
+
*/
|
|
7
|
+
const MutedCardFooter = ({ children, customStyles, ...props }) => (
|
|
8
|
+
<Box
|
|
9
|
+
sx={{
|
|
10
|
+
variant: 'styles.mutedCardFooter',
|
|
11
|
+
color: 'textMuted',
|
|
12
|
+
fontFamily: 'sans',
|
|
13
|
+
fontSize: 1,
|
|
14
|
+
...(typeof customStyles === 'object' && customStyles !== null ? customStyles : {})
|
|
15
|
+
}}
|
|
16
|
+
{...props}
|
|
17
|
+
>
|
|
18
|
+
{children}
|
|
19
|
+
</Box>
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
export default MutedCardFooter
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { ThemeUIProvider } from 'theme-ui'
|
|
3
|
+
|
|
4
|
+
import MutedCardFooter from './muted-card-footer.js'
|
|
5
|
+
|
|
6
|
+
const theme = { styles: { mutedCardFooter: { mt: 2 } } }
|
|
7
|
+
|
|
8
|
+
const wrap = ui => render(<ThemeUIProvider theme={theme}>{ui}</ThemeUIProvider>)
|
|
9
|
+
|
|
10
|
+
describe('MutedCardFooter', () => {
|
|
11
|
+
it('renders children', () => {
|
|
12
|
+
wrap(<MutedCardFooter>Footer text</MutedCardFooter>)
|
|
13
|
+
expect(screen.getByText('Footer text')).toBeInTheDocument()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('merges customStyles when an object', () => {
|
|
17
|
+
wrap(<MutedCardFooter customStyles={{ mt: 4 }}>A</MutedCardFooter>)
|
|
18
|
+
expect(screen.getByText('A')).toBeInTheDocument()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('ignores non-object customStyles', () => {
|
|
22
|
+
wrap(<MutedCardFooter customStyles='invalid'>B</MutedCardFooter>)
|
|
23
|
+
expect(screen.getByText('B')).toBeInTheDocument()
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Box } from '@theme-ui/components'
|
|
4
|
+
|
|
5
|
+
import { ChronogroveAnimatedPageBackground } from '../animated-page-background/index.js'
|
|
6
|
+
import { useDocumentColorModeSurface } from '../color-mode/index.js'
|
|
7
|
+
import { ChronogroveThemeProvider } from '../provider.js'
|
|
8
|
+
import chronogroveTheme from '../theme.js'
|
|
9
|
+
|
|
10
|
+
import { ChronogroveNextThemeUiColorModeRouteSync } from './theme-ui-color-mode-route-sync.js'
|
|
11
|
+
|
|
12
|
+
/** Mirrors Gatsby `RootWrapper`: sync html class, data attribute, and surface bg from the live theme. */
|
|
13
|
+
function DocumentColorModeSurface() {
|
|
14
|
+
useDocumentColorModeSurface()
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Default Next.js App Router shell: Theme UI provider, three.js Color Bends background (same as
|
|
20
|
+
* Gatsby home), document surface sync, and soft-navigation color-mode reconcile. Wrap with
|
|
21
|
+
* {@link ChronogroveNextEmotionRegistry} in `layout.jsx` outside this component.
|
|
22
|
+
*/
|
|
23
|
+
export function ChronogroveNextAppShell({ children, theme = chronogroveTheme }) {
|
|
24
|
+
return (
|
|
25
|
+
<ChronogroveThemeProvider theme={theme}>
|
|
26
|
+
<ChronogroveAnimatedPageBackground />
|
|
27
|
+
<DocumentColorModeSurface />
|
|
28
|
+
<Box sx={{ position: 'relative', zIndex: 1, bg: 'transparent', color: 'text' }}>
|
|
29
|
+
<ChronogroveNextThemeUiColorModeRouteSync />
|
|
30
|
+
{children}
|
|
31
|
+
</Box>
|
|
32
|
+
</ChronogroveThemeProvider>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { CacheProvider } from '@emotion/react'
|
|
5
|
+
import createCache from '@emotion/cache'
|
|
6
|
+
import { useServerInsertedHTML } from 'next/navigation'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Emotion cache for Next.js App Router: streaming SSR via `useServerInsertedHTML`, key `css`
|
|
10
|
+
* to match Theme UI / Chronogrove.
|
|
11
|
+
*
|
|
12
|
+
* Intercepts `cache.insert` and flushes only *new* rule names on each `useServerInsertedHTML`
|
|
13
|
+
* invocation. Do **not** use `Object.keys(cache.inserted)` (or similar) in the hook body — that
|
|
14
|
+
* re-emits every rule on every chunk during streaming SSR. Here, `flush()` drains a per-request
|
|
15
|
+
* queue so each rule is serialized once; `cache.inserted` stays as Emotion’s dedupe store.
|
|
16
|
+
*
|
|
17
|
+
* Next.js **pushes** a new callback from every render (`serverInsertedHTMLCallbacks.push`); all
|
|
18
|
+
* closures call the same `flush`, so only the first callback in a flush pass drains pending names.
|
|
19
|
+
*
|
|
20
|
+
* @see https://github.com/emotion-js/emotion/issues/2928
|
|
21
|
+
*
|
|
22
|
+
* @see https://nextjs.org/docs/app/building-your-application/styling/css-in-js
|
|
23
|
+
*/
|
|
24
|
+
export function ChronogroveNextEmotionRegistry({ children }) {
|
|
25
|
+
const [{ cache, flush }] = React.useState(() => {
|
|
26
|
+
const c = createCache({ key: 'css' })
|
|
27
|
+
c.compat = true
|
|
28
|
+
const prevInsert = c.insert
|
|
29
|
+
let pendingNames = []
|
|
30
|
+
c.insert = (...args) => {
|
|
31
|
+
const serialized = args[1]
|
|
32
|
+
if (serialized != null && c.inserted[serialized.name] === undefined) {
|
|
33
|
+
pendingNames.push(serialized.name)
|
|
34
|
+
}
|
|
35
|
+
return prevInsert(...args)
|
|
36
|
+
}
|
|
37
|
+
const flush = () => {
|
|
38
|
+
const names = pendingNames
|
|
39
|
+
pendingNames = []
|
|
40
|
+
return names
|
|
41
|
+
}
|
|
42
|
+
return { cache: c, flush }
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
useServerInsertedHTML(() => {
|
|
46
|
+
const names = flush()
|
|
47
|
+
if (names.length === 0) {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
let styles = ''
|
|
51
|
+
for (const name of names) {
|
|
52
|
+
const inserted = cache.inserted[name]
|
|
53
|
+
// `inserted` can be `true` for Global/keyframes when rules are already in a sheet (Next.js + Emotion pattern).
|
|
54
|
+
if (typeof inserted === 'string') {
|
|
55
|
+
styles += inserted
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
<style
|
|
60
|
+
key={cache.key}
|
|
61
|
+
data-emotion={`${cache.key} ${names.join(' ')}`}
|
|
62
|
+
dangerouslySetInnerHTML={{ __html: styles }}
|
|
63
|
+
/>
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return <CacheProvider value={cache}>{children}</CacheProvider>
|
|
68
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
jest.mock('next/navigation', () => ({
|
|
6
|
+
useServerInsertedHTML: jest.fn()
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
import React from 'react'
|
|
10
|
+
import { render } from '@testing-library/react'
|
|
11
|
+
import { Global, css } from '@emotion/react'
|
|
12
|
+
import { useServerInsertedHTML } from 'next/navigation'
|
|
13
|
+
|
|
14
|
+
import { ChronogroveNextEmotionRegistry } from './emotion-registry.js'
|
|
15
|
+
|
|
16
|
+
/** `className={css({...})}` does not always hit `cache.insert` in Jest/jsdom; `Global` does. */
|
|
17
|
+
function EmotionChild({ color }) {
|
|
18
|
+
return (
|
|
19
|
+
<Global
|
|
20
|
+
styles={css`
|
|
21
|
+
body {
|
|
22
|
+
color: ${color};
|
|
23
|
+
}
|
|
24
|
+
`}
|
|
25
|
+
/>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('ChronogroveNextEmotionRegistry', () => {
|
|
30
|
+
let flushCallbacks
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
flushCallbacks = []
|
|
34
|
+
useServerInsertedHTML.mockImplementation(cb => {
|
|
35
|
+
flushCallbacks.push(cb)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('flushes only new insertions per useServerInsertedHTML call (avoids duplicate style tags when streaming)', () => {
|
|
40
|
+
const { rerender } = render(
|
|
41
|
+
<ChronogroveNextEmotionRegistry>
|
|
42
|
+
<EmotionChild color='tomato' />
|
|
43
|
+
</ChronogroveNextEmotionRegistry>
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const first = flushCallbacks[flushCallbacks.length - 1]()
|
|
47
|
+
expect(first).not.toBeNull()
|
|
48
|
+
// `cache.inserted[id]` can be `true` for Global in jsdom; must not stringify to invalid CSS.
|
|
49
|
+
expect(first.props.dangerouslySetInnerHTML.__html).not.toMatch(/^true+$/)
|
|
50
|
+
|
|
51
|
+
expect(flushCallbacks[flushCallbacks.length - 1]()).toBeNull()
|
|
52
|
+
|
|
53
|
+
rerender(
|
|
54
|
+
<ChronogroveNextEmotionRegistry>
|
|
55
|
+
<EmotionChild color='steelblue' />
|
|
56
|
+
</ChronogroveNextEmotionRegistry>
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const afterNewRules = flushCallbacks[flushCallbacks.length - 1]()
|
|
60
|
+
expect(afterNewRules).not.toBeNull()
|
|
61
|
+
expect(flushCallbacks[flushCallbacks.length - 1]()).toBeNull()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Next.js registers `useServerInsertedHTML` once per render (`push` onto an array). All handlers
|
|
66
|
+
* share one `flush`; only the first invocation per drain should emit — not N copies of the full
|
|
67
|
+
* `cache.inserted` map.
|
|
68
|
+
*/
|
|
69
|
+
it('drains pending styles once when multiple handlers run (matches Next.js callback stacking)', () => {
|
|
70
|
+
const { rerender } = render(
|
|
71
|
+
<ChronogroveNextEmotionRegistry>
|
|
72
|
+
<EmotionChild color='tomato' />
|
|
73
|
+
</ChronogroveNextEmotionRegistry>
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expect(flushCallbacks).toHaveLength(1)
|
|
77
|
+
|
|
78
|
+
rerender(
|
|
79
|
+
<ChronogroveNextEmotionRegistry>
|
|
80
|
+
<EmotionChild color='tomato' />
|
|
81
|
+
</ChronogroveNextEmotionRegistry>
|
|
82
|
+
)
|
|
83
|
+
rerender(
|
|
84
|
+
<ChronogroveNextEmotionRegistry>
|
|
85
|
+
<EmotionChild color='tomato' />
|
|
86
|
+
</ChronogroveNextEmotionRegistry>
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
expect(flushCallbacks).toHaveLength(3)
|
|
90
|
+
|
|
91
|
+
const html0 = flushCallbacks[0]()
|
|
92
|
+
const html1 = flushCallbacks[1]()
|
|
93
|
+
const html2 = flushCallbacks[2]()
|
|
94
|
+
|
|
95
|
+
expect(html0).not.toBeNull()
|
|
96
|
+
expect(html1).toBeNull()
|
|
97
|
+
expect(html2).toBeNull()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { ChronogroveNextAppShell } from './app-shell.js'
|
|
2
|
+
export { ChronogroveNextEmotionRegistry } from './emotion-registry.js'
|
|
3
|
+
export { ChronogroveNextRootLayoutHead } from './root-layout-head.js'
|
|
4
|
+
export { ChronogroveNextThemeUiColorModeRouteSync } from './theme-ui-color-mode-route-sync.js'
|