@chronogrove/ui 0.80.0 → 0.82.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.
@@ -0,0 +1,106 @@
1
+ import React from 'react'
2
+ import { Box } from '@theme-ui/components'
3
+
4
+ /**
5
+ * Default loading indicator for widget CTAs (SVG spinner, no extra dependencies).
6
+ */
7
+ export const WidgetCtaLoadingIndicator = () => (
8
+ <Box
9
+ as='svg'
10
+ role='status'
11
+ aria-label='Loading'
12
+ width='24'
13
+ height='24'
14
+ viewBox='0 0 24 24'
15
+ sx={{
16
+ verticalAlign: 'middle',
17
+ color: 'primary',
18
+ animation: 'spin 0.8s linear infinite',
19
+ '@keyframes spin': { to: { transform: 'rotate(360deg)' } }
20
+ }}
21
+ >
22
+ <circle
23
+ cx='12'
24
+ cy='12'
25
+ r='10'
26
+ fill='none'
27
+ stroke='currentColor'
28
+ strokeWidth='3'
29
+ strokeLinecap='round'
30
+ strokeDasharray='31.4 31.4'
31
+ />
32
+ </Box>
33
+ )
34
+
35
+ const linkSxBase = {
36
+ variant: 'links.widgetCta',
37
+ '.read-more-icon': {
38
+ opacity: 0,
39
+ transition: 'all .3s ease',
40
+ ml: 1
41
+ },
42
+ '&:hover .read-more-icon, &:focus .read-more-icon': {
43
+ opacity: 1
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Text-style CTA used next to widget headlines. Pass `linkComponent` (e.g. Gatsby `Link` with `to`, or Next `Link` with `href`).
49
+ * External links: set `href` or `url` (no `linkComponent`).
50
+ */
51
+ export const WidgetCallToAction = ({
52
+ children,
53
+ isLoading = false,
54
+ loadingSlot,
55
+ title,
56
+ to,
57
+ url,
58
+ href,
59
+ linkComponent,
60
+ sx: sxProp,
61
+ ...rest
62
+ }) => {
63
+ const ctaSx = { ...linkSxBase, ...sxProp }
64
+
65
+ if (isLoading) {
66
+ return loadingSlot ?? <WidgetCtaLoadingIndicator />
67
+ }
68
+
69
+ if (linkComponent) {
70
+ const L = linkComponent
71
+ // Theme UI `sx` only applies when the outer node is a Theme UI `Box` (or other `sx`-aware
72
+ // primitive). Router links (Next.js `Link`, Gatsby `Link`) do not accept `sx` themselves.
73
+ if (href != null && href !== '') {
74
+ return (
75
+ <Box as={L} href={href} sx={ctaSx} title={title} {...rest}>
76
+ {children}
77
+ </Box>
78
+ )
79
+ }
80
+ if (to) {
81
+ return (
82
+ <Box as={L} to={to} sx={ctaSx} title={title} {...rest}>
83
+ {children}
84
+ </Box>
85
+ )
86
+ }
87
+ }
88
+
89
+ if (to) {
90
+ return (
91
+ <Box as='a' href={to} sx={ctaSx} title={title} {...rest}>
92
+ {children}
93
+ </Box>
94
+ )
95
+ }
96
+
97
+ const externalHref = href ?? url
98
+
99
+ return (
100
+ <Box as='a' href={externalHref} sx={ctaSx} title={title} {...rest}>
101
+ {children}
102
+ </Box>
103
+ )
104
+ }
105
+
106
+ export default WidgetCallToAction
@@ -0,0 +1,115 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { ThemeUIProvider } from 'theme-ui'
3
+
4
+ import WidgetCallToAction, { WidgetCtaLoadingIndicator } from './widget-call-to-action.js'
5
+
6
+ const LinkMock = ({ to, href, children, ...rest }) => (
7
+ <a href={href ?? to} data-testid='router-link' {...rest}>
8
+ {children}
9
+ </a>
10
+ )
11
+
12
+ const theme = { links: { widgetCta: { color: 'text' } } }
13
+
14
+ const wrap = ui => render(<ThemeUIProvider theme={theme}>{ui}</ThemeUIProvider>)
15
+
16
+ describe('WidgetCtaLoadingIndicator', () => {
17
+ it('renders svg', () => {
18
+ const { container } = wrap(<WidgetCtaLoadingIndicator />)
19
+ expect(container.querySelector('svg')).toBeInTheDocument()
20
+ })
21
+ })
22
+
23
+ describe('WidgetCallToAction', () => {
24
+ it('renders external link with url', () => {
25
+ wrap(
26
+ <WidgetCallToAction title='t' url='https://example.com'>
27
+ Go
28
+ </WidgetCallToAction>
29
+ )
30
+ const a = screen.getByRole('link')
31
+ expect(a).toHaveAttribute('href', 'https://example.com')
32
+ })
33
+
34
+ it('prefers href over url', () => {
35
+ wrap(
36
+ <WidgetCallToAction href='https://a.test' url='https://b.test'>
37
+ Go
38
+ </WidgetCallToAction>
39
+ )
40
+ expect(screen.getByRole('link')).toHaveAttribute('href', 'https://a.test')
41
+ })
42
+
43
+ it('uses linkComponent with to (Gatsby-style)', () => {
44
+ wrap(
45
+ <WidgetCallToAction linkComponent={LinkMock} to='/blog'>
46
+ Posts
47
+ </WidgetCallToAction>
48
+ )
49
+ expect(screen.getByTestId('router-link')).toHaveAttribute('href', '/blog')
50
+ })
51
+
52
+ it('uses linkComponent with href (Next.js-style)', () => {
53
+ wrap(
54
+ <WidgetCallToAction linkComponent={LinkMock} href='/dashboard'>
55
+ Dash
56
+ </WidgetCallToAction>
57
+ )
58
+ expect(screen.getByTestId('router-link')).toHaveAttribute('href', '/dashboard')
59
+ })
60
+
61
+ it('uses linkComponent with to when href is empty', () => {
62
+ wrap(
63
+ <WidgetCallToAction linkComponent={LinkMock} href='' to='/blog'>
64
+ Posts
65
+ </WidgetCallToAction>
66
+ )
67
+ expect(screen.getByTestId('router-link')).toHaveAttribute('href', '/blog')
68
+ })
69
+
70
+ it('ignores linkComponent for external url when no href or to', () => {
71
+ wrap(
72
+ <WidgetCallToAction linkComponent={LinkMock} url='https://example.com/out'>
73
+ Out
74
+ </WidgetCallToAction>
75
+ )
76
+ expect(screen.queryByTestId('router-link')).not.toBeInTheDocument()
77
+ expect(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com/out')
78
+ })
79
+
80
+ it('uses plain anchor when to without linkComponent', () => {
81
+ wrap(
82
+ <WidgetCallToAction to='/path' linkComponent={undefined}>
83
+ P
84
+ </WidgetCallToAction>
85
+ )
86
+ expect(screen.getByRole('link')).toHaveAttribute('href', '/path')
87
+ })
88
+
89
+ it('shows loading indicator', () => {
90
+ const { container } = wrap(
91
+ <WidgetCallToAction isLoading title='t'>
92
+ X
93
+ </WidgetCallToAction>
94
+ )
95
+ expect(container.querySelector('svg')).toBeInTheDocument()
96
+ })
97
+
98
+ it('uses custom loadingSlot', () => {
99
+ wrap(
100
+ <WidgetCallToAction isLoading loadingSlot={<span data-testid='ld'>wait</span>}>
101
+ X
102
+ </WidgetCallToAction>
103
+ )
104
+ expect(screen.getByTestId('ld')).toBeInTheDocument()
105
+ })
106
+
107
+ it('merges sx', () => {
108
+ wrap(
109
+ <WidgetCallToAction href='https://x.test' sx={{ m: 2 }}>
110
+ L
111
+ </WidgetCallToAction>
112
+ )
113
+ expect(screen.getByRole('link')).toBeInTheDocument()
114
+ })
115
+ })
@@ -0,0 +1,93 @@
1
+ import React from 'react'
2
+ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
3
+ import { Box, Heading } from '@theme-ui/components'
4
+
5
+ import ProfileMetricsBadge from './profile-metrics-badge.js'
6
+
7
+ const baseHeaderStyles = {
8
+ display: 'flex',
9
+ flexDirection: ['column', 'row'],
10
+ alignItems: ['center', 'baseline'],
11
+ justifyContent: ['center', 'space-between'],
12
+ gap: [2, 3],
13
+ flexWrap: 'wrap',
14
+ pb: 2,
15
+ // Soft separator: visible but not harsh (gray[4] in theme scale; fallback ~12% black)
16
+ borderBottom: '1px solid',
17
+ borderColor: theme => theme?.colors?.gray?.[4] ?? 'rgba(0,0,0,0.12)'
18
+ }
19
+
20
+ /**
21
+ * Groups headline + CTA so they sit together on the left; metrics stay on the right.
22
+ * Always row so headline + CTA stay on one line at every breakpoint (including mobile).
23
+ */
24
+ const titleGroupStyles = {
25
+ display: 'flex',
26
+ flexDirection: 'row',
27
+ alignItems: 'baseline',
28
+ gap: [1, 2],
29
+ order: 1
30
+ }
31
+
32
+ /**
33
+ * Override default heading margin, padding, and line-height so the headline and CTA
34
+ * align on the same baseline at every breakpoint. Theme/global styles often add
35
+ * vertical space to h2; zeroing it and using a tight line-height prevents offset.
36
+ * Baseline alignment for icon + text keeps the headline baseline consistent.
37
+ */
38
+ const headingStyles = {
39
+ fontSize: [4, 5],
40
+ display: 'flex',
41
+ alignItems: 'baseline',
42
+ m: 0,
43
+ py: 0,
44
+ pt: 0,
45
+ pb: 0,
46
+ lineHeight: 1
47
+ }
48
+
49
+ const metricsStyles = {
50
+ order: 2
51
+ }
52
+
53
+ const WidgetHeader = ({ aside, children, icon, metrics, metricsLoading }) => {
54
+ const hasMetrics = (Array.isArray(metrics) && metrics.length > 0) || metricsLoading
55
+ return (
56
+ <Box
57
+ as='header'
58
+ sx={{
59
+ ...baseHeaderStyles,
60
+ // Extra margin below header when metrics are present so spacing matches Latest Posts (no metrics)
61
+ mb: hasMetrics ? 4 : 2
62
+ }}
63
+ >
64
+ <Box sx={titleGroupStyles}>
65
+ <Heading as='h2' sx={headingStyles}>
66
+ {icon && (
67
+ <Box
68
+ as='span'
69
+ sx={{
70
+ display: 'inline-flex',
71
+ alignItems: 'baseline',
72
+ mr: 2,
73
+ fontSize: 4,
74
+ '& svg': { width: '1em', height: '1em' }
75
+ }}
76
+ >
77
+ <FontAwesomeIcon icon={icon} aria-hidden='true' />
78
+ </Box>
79
+ )}
80
+ {children}
81
+ </Heading>
82
+ {aside}
83
+ </Box>
84
+ {((Array.isArray(metrics) && metrics.length > 0) || metricsLoading) && (
85
+ <Box sx={metricsStyles}>
86
+ <ProfileMetricsBadge compact isLoading={metricsLoading} metrics={metrics} />
87
+ </Box>
88
+ )}
89
+ </Box>
90
+ )
91
+ }
92
+
93
+ export default WidgetHeader
@@ -0,0 +1,76 @@
1
+ import React from 'react'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { ThemeUIProvider } from 'theme-ui'
4
+
5
+ import chronogroveTheme from './theme.js'
6
+ import WidgetHeader from './widget-header.js'
7
+
8
+ jest.mock('@fortawesome/react-fontawesome', () => ({
9
+ FontAwesomeIcon: ({ icon }) => <span data-testid='fa-icon' data-icon={icon?.iconName ?? ''} aria-hidden />
10
+ }))
11
+
12
+ const aside = <div className='sidebar-content'>Sidebar</div>
13
+ const mockIcon = { iconName: 'spotify', prefix: 'fab' }
14
+
15
+ describe('WidgetHeader', () => {
16
+ it('matches the snapshot', () => {
17
+ const widgetTitle = 'Neat & Interesting Widget'
18
+ const { asFragment } = render(
19
+ <ThemeUIProvider theme={chronogroveTheme}>
20
+ <WidgetHeader aside={aside} icon={mockIcon}>
21
+ {widgetTitle}
22
+ </WidgetHeader>
23
+ </ThemeUIProvider>
24
+ )
25
+ expect(asFragment()).toMatchSnapshot()
26
+ })
27
+
28
+ it('renders metrics row when metrics are provided', () => {
29
+ const metrics = [{ displayName: 'Stars', id: 's', value: 12 }]
30
+ const { getByText } = render(
31
+ <ThemeUIProvider theme={chronogroveTheme}>
32
+ <WidgetHeader metrics={metrics}>Title</WidgetHeader>
33
+ </ThemeUIProvider>
34
+ )
35
+ expect(getByText('12 Stars')).toBeInTheDocument()
36
+ })
37
+
38
+ it('renders loading metrics placeholders when metricsLoading', () => {
39
+ const { container } = render(
40
+ <ThemeUIProvider theme={chronogroveTheme}>
41
+ <WidgetHeader metrics={[]} metricsLoading>
42
+ Title
43
+ </WidgetHeader>
44
+ </ThemeUIProvider>
45
+ )
46
+ const badges = container.querySelectorAll('[class*="css-"]')
47
+ expect(badges.length).toBeGreaterThan(0)
48
+ })
49
+
50
+ it('renders without icon', () => {
51
+ render(
52
+ <ThemeUIProvider theme={chronogroveTheme}>
53
+ <WidgetHeader aside={aside}>No icon</WidgetHeader>
54
+ </ThemeUIProvider>
55
+ )
56
+ expect(screen.getByText('No icon')).toBeInTheDocument()
57
+ expect(screen.queryByTestId('fa-icon')).not.toBeInTheDocument()
58
+ })
59
+
60
+ it('uses borderColor fallback when gray[4] is missing', () => {
61
+ const themeMissingGray4 = {
62
+ ...chronogroveTheme,
63
+ colors: {
64
+ ...chronogroveTheme.colors,
65
+ gray: { 0: '#111', 1: '#222', 2: '#333', 3: '#444' }
66
+ }
67
+ }
68
+ const { container } = render(
69
+ <ThemeUIProvider theme={themeMissingGray4}>
70
+ <WidgetHeader>Border test</WidgetHeader>
71
+ </ThemeUIProvider>
72
+ )
73
+ expect(container.querySelector('header')).toBeTruthy()
74
+ expect(screen.getByText('Border test')).toBeInTheDocument()
75
+ })
76
+ })
@@ -0,0 +1,83 @@
1
+ import React from 'react'
2
+ import { Box } from '@theme-ui/components'
3
+ import { useThemeUI } from 'theme-ui'
4
+
5
+ import isDarkMode from './helpers/isDarkMode.js'
6
+
7
+ const sectionSx = {
8
+ mb: 4,
9
+ pt: [0, 3, 4],
10
+ pb: [0, 3, 4]
11
+ }
12
+
13
+ /**
14
+ * Wraps a dashboard widget: vertical spacing and optional fatal-error overlay.
15
+ */
16
+ const WidgetSection = ({ children, hasFatalError, id, styleOverrides = {}, ...props }) => {
17
+ const { colorMode } = useThemeUI()
18
+ const darkMode = isDarkMode(colorMode)
19
+
20
+ return (
21
+ <Box
22
+ as='section'
23
+ sx={{
24
+ ...sectionSx,
25
+ ...styleOverrides,
26
+ ...(hasFatalError
27
+ ? {
28
+ position: 'relative'
29
+ }
30
+ : {})
31
+ }}
32
+ {...(id ? { id } : {})}
33
+ {...props}
34
+ >
35
+ {hasFatalError && (
36
+ <Box
37
+ sx={{
38
+ alignItems: 'center',
39
+ bottom: 0,
40
+ display: 'flex',
41
+ justifyContent: 'center',
42
+ left: 0,
43
+ position: 'absolute',
44
+ right: 0,
45
+ top: 0
46
+ }}
47
+ >
48
+ <Box
49
+ sx={{
50
+ background: darkMode ? '#252e3c' : 'white',
51
+ borderLeft: '2px solid red',
52
+ borderRight: '2px solid red',
53
+ borderRadius: '2px',
54
+ boxShadow: 'xl',
55
+ py: 3,
56
+ px: 4,
57
+ zIndex: 480
58
+ }}
59
+ >
60
+ <h4>Something went wrong</h4>
61
+ <p>Failed to load this widget.</p>
62
+ </Box>
63
+ <Box
64
+ sx={{
65
+ top: 0,
66
+ right: 0,
67
+ bottom: 0,
68
+ left: 0,
69
+ background: darkMode
70
+ ? 'radial-gradient(rgba(14.5,18,23.5,0.4) 20%, transparent 50%);'
71
+ : 'radial-gradient(rgba(255, 255, 255, 0.4) 20%, transparent 50%)',
72
+ position: 'absolute',
73
+ zIndex: 470
74
+ }}
75
+ />
76
+ </Box>
77
+ )}
78
+ {children}
79
+ </Box>
80
+ )
81
+ }
82
+
83
+ export default WidgetSection
@@ -0,0 +1,59 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { ThemeUIProvider } from 'theme-ui'
3
+
4
+ import WidgetSection from './widget-section.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
+ describe('WidgetSection', () => {
17
+ it('renders children', () => {
18
+ useThemeUI.mockReturnValue({ colorMode: 'default' })
19
+ render(
20
+ <ThemeUIProvider theme={{}}>
21
+ <WidgetSection>
22
+ <span>content</span>
23
+ </WidgetSection>
24
+ </ThemeUIProvider>
25
+ )
26
+ expect(screen.getByText('content')).toBeInTheDocument()
27
+ })
28
+
29
+ it('sets id when provided', () => {
30
+ useThemeUI.mockReturnValue({ colorMode: 'default' })
31
+ render(
32
+ <ThemeUIProvider theme={{}}>
33
+ <WidgetSection id='w1'>x</WidgetSection>
34
+ </ThemeUIProvider>
35
+ )
36
+ expect(document.getElementById('w1')).toBeTruthy()
37
+ })
38
+
39
+ it('shows fatal error overlay', () => {
40
+ useThemeUI.mockReturnValue({ colorMode: 'dark' })
41
+ render(
42
+ <ThemeUIProvider theme={{}}>
43
+ <WidgetSection hasFatalError>inside</WidgetSection>
44
+ </ThemeUIProvider>
45
+ )
46
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument()
47
+ expect(screen.getByText('inside')).toBeInTheDocument()
48
+ })
49
+
50
+ it('uses light overlay colors in light mode', () => {
51
+ useThemeUI.mockReturnValue({ colorMode: 'default' })
52
+ render(
53
+ <ThemeUIProvider theme={{}}>
54
+ <WidgetSection hasFatalError />
55
+ </ThemeUIProvider>
56
+ )
57
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument()
58
+ })
59
+ })
@@ -1,42 +0,0 @@
1
- 'use client'
2
-
3
- import { Box } from '@theme-ui/components'
4
- import { useColorMode } from 'theme-ui'
5
-
6
- import isDarkMode from './helpers/isDarkMode.js'
7
-
8
- /**
9
- * Fixed viewport layer behind page content (`z-index: 0`), matching the role of
10
- * `AnimatedPageBackground` on the Gatsby home: a real surface under `z-index: 1` UI so
11
- * frosted panels (`backdrop-filter` + `panel-background`) have something to blur and tint
12
- * against. The full site uses WebGL Color Bends there; this package ships a **lightweight**
13
- * CSS gradient treatment only (no three.js).
14
- */
15
- export function ChronogrovePageBackdrop() {
16
- const [colorMode] = useColorMode()
17
- const dark = isDarkMode(colorMode)
18
-
19
- return (
20
- <Box
21
- aria-hidden
22
- sx={{
23
- position: 'fixed',
24
- inset: 0,
25
- width: '100vw',
26
- minHeight: '100vh',
27
- zIndex: 0,
28
- pointerEvents: 'none',
29
- bg: 'background',
30
- // Dark: subtle primary/secondary glows — same intent as Color Bends, fraction of the cost.
31
- backgroundImage: dark
32
- ? `
33
- radial-gradient(ellipse 120% 85% at 50% -15%, rgba(74, 158, 255, 0.16) 0%, transparent 52%),
34
- radial-gradient(ellipse 90% 70% at 95% 85%, rgba(113, 30, 155, 0.12) 0%, transparent 48%),
35
- radial-gradient(ellipse 70% 50% at 10% 60%, rgba(128, 0, 128, 0.08) 0%, transparent 45%)
36
- `
37
- : 'none',
38
- transition: 'background-image 0.3s ease'
39
- }}
40
- />
41
- )
42
- }
@@ -1,41 +0,0 @@
1
- /**
2
- * @jest-environment jsdom
3
- */
4
- import React from 'react'
5
- import { render } from '@testing-library/react'
6
- import { ThemeUIProvider } from 'theme-ui'
7
-
8
- import chronogroveTheme from './theme.js'
9
- import { ChronogrovePageBackdrop } from './page-backdrop.js'
10
-
11
- describe('ChronogrovePageBackdrop', () => {
12
- beforeEach(() => {
13
- window.localStorage.removeItem('theme-ui-color-mode')
14
- })
15
-
16
- it('renders a fixed backdrop layer', () => {
17
- const { container } = render(
18
- <ThemeUIProvider theme={chronogroveTheme}>
19
- <ChronogrovePageBackdrop />
20
- </ThemeUIProvider>
21
- )
22
- const layer = container.querySelector('[aria-hidden="true"]')
23
- expect(layer).toBeTruthy()
24
- })
25
-
26
- it('adds gradient overlays in dark mode', () => {
27
- window.localStorage.setItem('theme-ui-color-mode', 'dark')
28
- const { container } = render(
29
- <ThemeUIProvider
30
- theme={{
31
- ...chronogroveTheme,
32
- config: { useLocalStorage: true, useColorSchemeMediaQuery: false }
33
- }}
34
- >
35
- <ChronogrovePageBackdrop />
36
- </ThemeUIProvider>
37
- )
38
- const layer = container.querySelector('[aria-hidden="true"]')
39
- expect(window.getComputedStyle(layer).backgroundImage).toMatch(/radial-gradient/)
40
- })
41
- })