@chronogrove/ui 0.77.0 → 0.79.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 CHANGED
@@ -12,21 +12,30 @@ pnpm add @chronogrove/ui
12
12
 
13
13
  Use **`pnpm publish`** for releases so `workspace:` dependencies in dependents are rewritten; see [pnpm workspaces — publishing](https://pnpm.io/workspaces#publishing-workspace-packages).
14
14
 
15
+ **Shared dependencies with `gatsby-theme-chronogrove`:** both packages depend on Theme UI, Emotion, and related libraries, with versions driven by the root [pnpm catalog](../../pnpm-workspace.yaml). When you bump those catalog entries, update **`packages/ui`** and **`theme`** in the **same change** so the theme and `@chronogrove/ui` stay aligned and you avoid duplicate or mismatched installs.
16
+
15
17
  ## Subpath exports
16
18
 
17
19
  Prefer deep imports so bundles stay lean:
18
20
 
19
- | Import path | Contents |
20
- | ------------------------------- | -------------------------------------------------------------------------------------------------- |
21
- | `@chronogrove/ui` | `ChronogroveThemeProvider` |
22
- | `@chronogrove/ui/theme` | Default Theme UI theme object + named exports |
23
- | `@chronogrove/ui/provider` | `ChronogroveThemeProvider` |
24
- | `@chronogrove/ui/color-mode` | Storage key, reconcile event, SSR inline builders, `resolveChronogroveSurfaceColors`, browser sync |
25
- | `@chronogrove/ui/emotion-cache` | `createChronogroveEmotionCache`, `getChronogroveEmotionCache` |
26
- | `@chronogrove/ui/button` | Theme UI `components` button |
27
- | `@chronogrove/ui/color-toggle` | Theme UI + `@theme-toggles/react` toggle |
28
- | `@chronogrove/ui/skip-nav` | `SkipNavLink`, `SkipNavContent` |
29
- | `@chronogrove/ui/is-dark-mode` | `colorMode === 'dark'` helper |
21
+ | Import path | Contents |
22
+ | ----------------------------------- | -------------------------------------------------------------------------------------------------- |
23
+ | `@chronogrove/ui` | `ChronogroveThemeProvider` |
24
+ | `@chronogrove/ui/theme` | Default Theme UI theme object + named exports |
25
+ | `@chronogrove/ui/provider` | `ChronogroveThemeProvider` |
26
+ | `@chronogrove/ui/color-mode` | Storage key, reconcile event, SSR inline builders, `resolveChronogroveSurfaceColors`, browser sync |
27
+ | `@chronogrove/ui/emotion-cache` | `createChronogroveEmotionCache`, `getChronogroveEmotionCache` |
28
+ | `@chronogrove/ui/button` | Theme UI `components` button |
29
+ | `@chronogrove/ui/color-toggle` | Theme UI + `@theme-toggles/react` toggle |
30
+ | `@chronogrove/ui/skip-nav` | `SkipNavLink`, `SkipNavContent` |
31
+ | `@chronogrove/ui/is-dark-mode` | `colorMode === 'dark'` helper |
32
+ | `@chronogrove/ui/color-utils` | `hexToRgb`, `hexToRgba`, `BUTTON_PRIMARY_COLORS` |
33
+ | `@chronogrove/ui/action-button` | Outline CTA as `<button>` or `<a>` |
34
+ | `@chronogrove/ui/pagination-button` | Compact paginator control |
35
+ | `@chronogrove/ui/lazy-load` | Defer children until in viewport (`react-intersection-observer`) |
36
+ | `@chronogrove/ui/header` | Masthead shell (`variant: styles.Header`) |
37
+ | `@chronogrove/ui/page-header` | Blog-style `h1` heading (`p-name`) |
38
+ | `@chronogrove/ui/gatsby` | Color-mode Gatsby SSR/browser helpers |
30
39
 
31
40
  ## Next.js (App Router)
32
41
 
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@chronogrove/ui",
3
- "version": "0.77.0",
3
+ "version": "0.79.0",
4
4
  "description": "Chronogrove Theme UI theme, color mode helpers, and shared UI primitives",
5
5
  "license": "MIT",
6
+ "type": "module",
7
+ "files": [
8
+ "src",
9
+ "README.md"
10
+ ],
6
11
  "repository": {
7
12
  "type": "git",
8
13
  "url": "https://github.com/chrisvogt/gatsby-theme-chronogrove.git",
@@ -46,6 +51,30 @@
46
51
  "import": "./src/helpers/isDarkMode.js",
47
52
  "default": "./src/helpers/isDarkMode.js"
48
53
  },
54
+ "./color-utils": {
55
+ "import": "./src/color-utils.js",
56
+ "default": "./src/color-utils.js"
57
+ },
58
+ "./action-button": {
59
+ "import": "./src/action-button.js",
60
+ "default": "./src/action-button.js"
61
+ },
62
+ "./pagination-button": {
63
+ "import": "./src/pagination-button.js",
64
+ "default": "./src/pagination-button.js"
65
+ },
66
+ "./lazy-load": {
67
+ "import": "./src/lazy-load.js",
68
+ "default": "./src/lazy-load.js"
69
+ },
70
+ "./header": {
71
+ "import": "./src/header.js",
72
+ "default": "./src/header.js"
73
+ },
74
+ "./page-header": {
75
+ "import": "./src/page-header.js",
76
+ "default": "./src/page-header.js"
77
+ },
49
78
  "./gatsby": {
50
79
  "import": "./src/gatsby/index.js",
51
80
  "default": "./src/gatsby/index.js"
@@ -61,6 +90,7 @@
61
90
  "@theme-toggles/react": "^4.1.0",
62
91
  "@theme-ui/components": "^0.17.4",
63
92
  "@theme-ui/presets": "^0.17.4",
93
+ "react-intersection-observer": "^10.0.3",
64
94
  "theme-ui": "^0.17.4"
65
95
  },
66
96
  "devDependencies": {
@@ -0,0 +1,69 @@
1
+ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2
+
3
+ exports[`Header renders with children 1`] = `
4
+ <DocumentFragment>
5
+ <header
6
+ class="css-1u8qly9"
7
+ role="banner"
8
+ >
9
+ <div
10
+ class="css-1u8qly9"
11
+ >
12
+ <h1>
13
+ Test Header
14
+ </h1>
15
+ </div>
16
+ </header>
17
+ </DocumentFragment>
18
+ `;
19
+
20
+ exports[`Header renders with custom styles 1`] = `
21
+ <DocumentFragment>
22
+ <header
23
+ class="css-1u8qly9"
24
+ role="banner"
25
+ >
26
+ <div
27
+ class="css-10n8us"
28
+ >
29
+ <h1>
30
+ Test Header with Styles
31
+ </h1>
32
+ </div>
33
+ </header>
34
+ </DocumentFragment>
35
+ `;
36
+
37
+ exports[`Header renders with empty styles object 1`] = `
38
+ <DocumentFragment>
39
+ <header
40
+ class="css-1u8qly9"
41
+ role="banner"
42
+ >
43
+ <div
44
+ class="css-1u8qly9"
45
+ >
46
+ <h1>
47
+ Test Header with Empty Styles
48
+ </h1>
49
+ </div>
50
+ </header>
51
+ </DocumentFragment>
52
+ `;
53
+
54
+ exports[`Header renders without styles prop 1`] = `
55
+ <DocumentFragment>
56
+ <header
57
+ class="css-1u8qly9"
58
+ role="banner"
59
+ >
60
+ <div
61
+ class="css-1u8qly9"
62
+ >
63
+ <h1>
64
+ Test Header without Styles
65
+ </h1>
66
+ </div>
67
+ </header>
68
+ </DocumentFragment>
69
+ `;
@@ -0,0 +1,11 @@
1
+ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2
+
3
+ exports[`PageHeader matches snapshot 1`] = `
4
+ <DocumentFragment>
5
+ <h1
6
+ class="p-name css-1sus4jf"
7
+ >
8
+ Hello, World!
9
+ </h1>
10
+ </DocumentFragment>
11
+ `;
@@ -0,0 +1,83 @@
1
+ /** @jsx jsx */
2
+ import React from 'react'
3
+ import { jsx, useThemeUI } from 'theme-ui'
4
+ import isDarkMode from './helpers/isDarkMode.js'
5
+ import { hexToRgb } from './color-utils.js'
6
+
7
+ const ActionButton = ({ children, href, onClick, variant = 'primary', size = 'medium', icon, ...props }) => {
8
+ const { colorMode, theme } = useThemeUI()
9
+ const darkModeActive = isDarkMode(colorMode)
10
+
11
+ const colorVariants = {
12
+ secondary: { light: '#666', dark: '#888' },
13
+ success: { light: '#28a745', dark: '#4CAF50' },
14
+ warning: { light: '#ffc107', dark: '#FF9800' },
15
+ danger: { light: '#dc3545', dark: '#f44336' }
16
+ }
17
+ const isPrimary = variant === 'primary' || !colorVariants[variant]
18
+ const primaryColor = isPrimary
19
+ ? (theme?.colors?.primary ?? '#422EA3')
20
+ : darkModeActive
21
+ ? colorVariants[variant].dark
22
+ : colorVariants[variant].light
23
+ const rgb = isPrimary ? (theme?.colors?.primaryRgb ?? '66, 46, 163') : hexToRgb(primaryColor)
24
+
25
+ const sizeVariants = {
26
+ small: { fontSize: ['11px', '12px'], padding: '6px 10px', gap: 1 },
27
+ medium: { fontSize: ['12px', '13px'], padding: '8px 12px', gap: 1 },
28
+ large: { fontSize: ['13px', '14px'], padding: '10px 16px', gap: 2 }
29
+ }
30
+ const sizeStyles = sizeVariants[size] || sizeVariants.medium
31
+
32
+ const baseStyles = {
33
+ color: primaryColor,
34
+ textDecoration: 'none',
35
+ fontWeight: 'medium',
36
+ fontSize: sizeStyles.fontSize,
37
+ display: 'inline-flex',
38
+ alignItems: 'center',
39
+ gap: sizeStyles.gap,
40
+ padding: sizeStyles.padding,
41
+ borderRadius: '6px',
42
+ background: `rgba(${rgb}, 0.1)`,
43
+ border: `1px solid rgba(${rgb}, 0.2)`,
44
+ transition: 'all 0.2s ease',
45
+ cursor: 'pointer',
46
+ outline: 'none',
47
+ '&:hover': {
48
+ background: `rgba(${rgb}, 0.2)`,
49
+ textDecoration: 'none',
50
+ transform: 'scale(1.02)'
51
+ },
52
+ '&:focus': {
53
+ outline: 'none',
54
+ boxShadow: `0 0 0 2px ${primaryColor}20`
55
+ },
56
+ '&:active': {
57
+ transform: 'scale(0.98)'
58
+ }
59
+ }
60
+
61
+ const content = (
62
+ <>
63
+ {children}
64
+ {icon && icon}
65
+ </>
66
+ )
67
+
68
+ if (href) {
69
+ return (
70
+ <a href={href} sx={baseStyles} {...props}>
71
+ {content}
72
+ </a>
73
+ )
74
+ }
75
+
76
+ return (
77
+ <button type='button' onClick={onClick} sx={baseStyles} {...props}>
78
+ {content}
79
+ </button>
80
+ )
81
+ }
82
+
83
+ export default ActionButton
@@ -0,0 +1,201 @@
1
+ /** @jsx jsx */
2
+ import { jsx } from 'theme-ui'
3
+ import { render, screen, fireEvent } from '@testing-library/react'
4
+ import { ThemeUIProvider } from 'theme-ui'
5
+
6
+ import ActionButton from './action-button.js'
7
+ import { BUTTON_PRIMARY_COLORS } from './color-utils.js'
8
+
9
+ const mockUseThemeUI = jest.fn(() => ({
10
+ colorMode: 'default',
11
+ theme: {
12
+ colors: {
13
+ primary: BUTTON_PRIMARY_COLORS.light,
14
+ primaryRgb: '66, 46, 163'
15
+ }
16
+ }
17
+ }))
18
+
19
+ jest.mock('theme-ui', () => ({
20
+ ...jest.requireActual('theme-ui'),
21
+ useThemeUI: () => mockUseThemeUI()
22
+ }))
23
+
24
+ const mockTheme = {
25
+ colors: {
26
+ primary: BUTTON_PRIMARY_COLORS.light,
27
+ primaryRgb: '66, 46, 163',
28
+ modes: {
29
+ dark: {
30
+ text: '#ffffff',
31
+ background: '#000000'
32
+ }
33
+ }
34
+ }
35
+ }
36
+
37
+ const renderWithProviders = (component, customTheme = null) => {
38
+ const themeToUse = customTheme ?? mockTheme
39
+ return render(<ThemeUIProvider theme={themeToUse}>{component}</ThemeUIProvider>)
40
+ }
41
+
42
+ describe('ActionButton', () => {
43
+ it('renders as a button by default', () => {
44
+ renderWithProviders(<ActionButton>Test Button</ActionButton>)
45
+
46
+ const button = screen.getByRole('button', { name: /test button/i })
47
+ expect(button).toBeInTheDocument()
48
+ expect(button.tagName).toBe('BUTTON')
49
+ })
50
+
51
+ it('renders as a link when href is provided', () => {
52
+ renderWithProviders(<ActionButton href='/test'>Test Link</ActionButton>)
53
+
54
+ const link = screen.getByRole('link')
55
+ expect(link).toBeInTheDocument()
56
+ expect(link.tagName).toBe('A')
57
+ expect(link).toHaveAttribute('href', '/test')
58
+ })
59
+
60
+ it('handles click events', () => {
61
+ const handleClick = jest.fn()
62
+ renderWithProviders(<ActionButton onClick={handleClick}>Click Me</ActionButton>)
63
+
64
+ const button = screen.getByRole('button', { name: /click me/i })
65
+ fireEvent.click(button)
66
+
67
+ expect(handleClick).toHaveBeenCalledTimes(1)
68
+ })
69
+
70
+ it('applies primary variant styles by default', () => {
71
+ renderWithProviders(<ActionButton>Primary Button</ActionButton>)
72
+
73
+ const button = screen.getByRole('button', { name: /primary button/i })
74
+ expect(button).toHaveStyle({ fontWeight: 'medium' })
75
+ expect(button).toBeInTheDocument()
76
+ })
77
+
78
+ it('applies secondary variant styles', () => {
79
+ renderWithProviders(<ActionButton variant='secondary'>Secondary Button</ActionButton>)
80
+
81
+ const button = screen.getByRole('button', { name: /secondary button/i })
82
+ expect(button).toHaveStyle({
83
+ color: '#666'
84
+ })
85
+ })
86
+
87
+ it('applies small size styles', () => {
88
+ renderWithProviders(<ActionButton size='small'>Small Button</ActionButton>)
89
+
90
+ const button = screen.getByRole('button', { name: /small button/i })
91
+ expect(button).toHaveStyle({
92
+ fontSize: '11px',
93
+ padding: '6px 10px'
94
+ })
95
+ })
96
+
97
+ it('applies large size styles', () => {
98
+ renderWithProviders(<ActionButton size='large'>Large Button</ActionButton>)
99
+
100
+ const button = screen.getByRole('button', { name: /large button/i })
101
+ expect(button).toHaveStyle({
102
+ fontSize: '13px',
103
+ padding: '10px 16px'
104
+ })
105
+ })
106
+
107
+ it('renders with icon', () => {
108
+ const TestIcon = () => <span data-testid='test-icon'>→</span>
109
+ renderWithProviders(<ActionButton icon={<TestIcon />}>Button with Icon</ActionButton>)
110
+
111
+ expect(screen.getByTestId('test-icon')).toBeInTheDocument()
112
+ expect(screen.getByRole('button', { name: /button with icon/i })).toBeInTheDocument()
113
+ })
114
+
115
+ it('passes through additional props', () => {
116
+ renderWithProviders(
117
+ <ActionButton data-testid='custom-button' aria-label='Custom label'>
118
+ Custom Button
119
+ </ActionButton>
120
+ )
121
+
122
+ const button = screen.getByTestId('custom-button')
123
+ expect(button).toHaveAttribute('aria-label', 'Custom label')
124
+ })
125
+
126
+ it('has proper accessibility attributes', () => {
127
+ renderWithProviders(<ActionButton>Accessible Button</ActionButton>)
128
+
129
+ const button = screen.getByRole('button', { name: /accessible button/i })
130
+ expect(button).toHaveAttribute('type', 'button')
131
+ })
132
+
133
+ it('falls back to primary variant when invalid variant is provided', () => {
134
+ renderWithProviders(<ActionButton variant='invalid'>Invalid Variant</ActionButton>)
135
+
136
+ const button = screen.getByRole('button', { name: /invalid variant/i })
137
+ expect(button).toBeInTheDocument()
138
+ expect(button).toHaveStyle({ fontWeight: 'medium' })
139
+ })
140
+
141
+ it('falls back to medium size when invalid size is provided', () => {
142
+ renderWithProviders(<ActionButton size='invalid'>Invalid Size</ActionButton>)
143
+
144
+ const button = screen.getByRole('button', { name: /invalid size/i })
145
+ expect(button).toHaveStyle({
146
+ fontSize: '12px',
147
+ padding: '8px 12px'
148
+ })
149
+ })
150
+
151
+ describe('theme fallbacks', () => {
152
+ beforeEach(() => {
153
+ mockUseThemeUI.mockReturnValue({
154
+ colorMode: 'default',
155
+ theme: {
156
+ colors: {
157
+ primary: BUTTON_PRIMARY_COLORS.light,
158
+ primaryRgb: '66, 46, 163'
159
+ }
160
+ }
161
+ })
162
+ })
163
+
164
+ it('uses fallback primary color when theme.colors.primary is undefined', () => {
165
+ mockUseThemeUI.mockReturnValueOnce({
166
+ colorMode: 'default',
167
+ theme: { colors: {} }
168
+ })
169
+
170
+ renderWithProviders(<ActionButton>Fallback Test</ActionButton>)
171
+
172
+ const button = screen.getByRole('button', { name: /fallback test/i })
173
+ expect(button).toBeInTheDocument()
174
+ expect(button).toHaveStyle({ fontWeight: 'medium' })
175
+ })
176
+
177
+ it('uses fallback primaryRgb when theme.colors.primaryRgb is undefined', () => {
178
+ mockUseThemeUI.mockReturnValueOnce({
179
+ colorMode: 'default',
180
+ theme: { colors: { primary: '#422EA3' } }
181
+ })
182
+
183
+ renderWithProviders(<ActionButton>Fallback RGB Test</ActionButton>)
184
+
185
+ const button = screen.getByRole('button', { name: /fallback rgb test/i })
186
+ expect(button).toBeInTheDocument()
187
+ })
188
+
189
+ it('uses fallback when theme itself is undefined', () => {
190
+ mockUseThemeUI.mockReturnValueOnce({
191
+ colorMode: 'default',
192
+ theme: undefined
193
+ })
194
+
195
+ renderWithProviders(<ActionButton>Undefined Theme Test</ActionButton>)
196
+
197
+ const button = screen.getByRole('button', { name: /undefined theme test/i })
198
+ expect(button).toBeInTheDocument()
199
+ })
200
+ })
201
+ })
@@ -1,7 +1,7 @@
1
1
  import React from 'react'
2
2
  import { useColorMode } from 'theme-ui'
3
3
  import { Expand } from '@theme-toggles/react'
4
- import isDarkMode from '@chronogrove/ui/is-dark-mode'
4
+ import isDarkMode from './helpers/isDarkMode.js'
5
5
 
6
6
  export default function ColorToggle() {
7
7
  const [colorMode, setColorMode] = useColorMode()
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Shared color utilities and constants for consistent button and UI styling.
3
+ * Single source of truth for primary button colors and hex conversion.
4
+ */
5
+
6
+ /** Fallback RGB string when hex is invalid (blue) */
7
+ const HEX_TO_RGB_FALLBACK = '74, 158, 255'
8
+
9
+ /**
10
+ * Converts hex color to RGB string for use in rgba()
11
+ * @param {string} hex - Hex color code (with or without #)
12
+ * @returns {string} RGB values as comma-separated string (e.g. "66, 46, 163")
13
+ */
14
+ export const hexToRgb = hex => {
15
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
16
+ if (!result) return HEX_TO_RGB_FALLBACK
17
+ return `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}`
18
+ }
19
+
20
+ /**
21
+ * Converts hex color to rgba string
22
+ * @param {string} hex - Hex color code (with or without #)
23
+ * @param {number} alpha - Alpha value 0–1
24
+ * @returns {string} rgba() CSS value
25
+ */
26
+ export const hexToRgba = (hex, alpha) => {
27
+ return `rgba(${hexToRgb(hex)}, ${alpha})`
28
+ }
29
+
30
+ /**
31
+ * Primary button/CTA colors for light and dark mode.
32
+ * Aligns with theme colors.primary and theme.colors.primaryRgb.
33
+ */
34
+ export const BUTTON_PRIMARY_COLORS = {
35
+ light: '#422EA3',
36
+ dark: '#4a9eff'
37
+ }
@@ -0,0 +1,43 @@
1
+ import { hexToRgb, hexToRgba, BUTTON_PRIMARY_COLORS } from './color-utils'
2
+
3
+ describe('hexToRgb', () => {
4
+ it('converts hex with hash to RGB', () => {
5
+ expect(hexToRgb('#422EA3')).toBe('66, 46, 163')
6
+ })
7
+
8
+ it('converts hex without hash to RGB', () => {
9
+ expect(hexToRgb('4a9eff')).toBe('74, 158, 255')
10
+ })
11
+
12
+ it('returns fallback for invalid hex', () => {
13
+ expect(hexToRgb('invalid')).toBe('74, 158, 255')
14
+ })
15
+
16
+ it('returns fallback for empty string', () => {
17
+ expect(hexToRgb('')).toBe('74, 158, 255')
18
+ })
19
+ })
20
+
21
+ describe('hexToRgba', () => {
22
+ it('converts hex to rgba string', () => {
23
+ expect(hexToRgba('#422EA3', 0.5)).toBe('rgba(66, 46, 163, 0.5)')
24
+ })
25
+
26
+ it('accepts hex without hash', () => {
27
+ expect(hexToRgba('4a9eff', 1)).toBe('rgba(74, 158, 255, 1)')
28
+ })
29
+
30
+ it('uses fallback RGB for invalid hex', () => {
31
+ expect(hexToRgba('invalid', 0.2)).toBe('rgba(74, 158, 255, 0.2)')
32
+ })
33
+ })
34
+
35
+ describe('BUTTON_PRIMARY_COLORS', () => {
36
+ it('exports light mode color', () => {
37
+ expect(BUTTON_PRIMARY_COLORS.light).toBe('#422EA3')
38
+ })
39
+
40
+ it('exports dark mode color', () => {
41
+ expect(BUTTON_PRIMARY_COLORS.dark).toBe('#4a9eff')
42
+ })
43
+ })
package/src/header.js ADDED
@@ -0,0 +1,28 @@
1
+ /** @jsx jsx */
2
+ import { jsx } from 'theme-ui'
3
+
4
+ /**
5
+ * Header
6
+ *
7
+ * A decorative masthead element that can be used across page layouts.
8
+ */
9
+ const Header = ({ children, styles }) => {
10
+ return (
11
+ <header
12
+ role='banner'
13
+ sx={{
14
+ variant: 'styles.Header'
15
+ }}
16
+ >
17
+ <div
18
+ sx={{
19
+ ...(styles ? styles : {})
20
+ }}
21
+ >
22
+ {children}
23
+ </div>
24
+ </header>
25
+ )
26
+ }
27
+
28
+ export default Header
@@ -0,0 +1,47 @@
1
+ import React from 'react'
2
+ import { render } from '@testing-library/react'
3
+ import '@testing-library/jest-dom'
4
+
5
+ import Header from './header.js'
6
+
7
+ describe('Header', () => {
8
+ it('renders with children', () => {
9
+ const { asFragment } = render(
10
+ <Header>
11
+ <h1>Test Header</h1>
12
+ </Header>
13
+ )
14
+ expect(asFragment()).toMatchSnapshot()
15
+ })
16
+
17
+ it('renders with custom styles', () => {
18
+ const customStyles = {
19
+ backgroundColor: 'red',
20
+ color: 'white'
21
+ }
22
+ const { asFragment } = render(
23
+ <Header styles={customStyles}>
24
+ <h1>Test Header with Styles</h1>
25
+ </Header>
26
+ )
27
+ expect(asFragment()).toMatchSnapshot()
28
+ })
29
+
30
+ it('renders without styles prop', () => {
31
+ const { asFragment } = render(
32
+ <Header>
33
+ <h1>Test Header without Styles</h1>
34
+ </Header>
35
+ )
36
+ expect(asFragment()).toMatchSnapshot()
37
+ })
38
+
39
+ it('renders with empty styles object', () => {
40
+ const { asFragment } = render(
41
+ <Header styles={{}}>
42
+ <h1>Test Header with Empty Styles</h1>
43
+ </Header>
44
+ )
45
+ expect(asFragment()).toMatchSnapshot()
46
+ })
47
+ })
@@ -0,0 +1,41 @@
1
+ /** @jsx jsx */
2
+ import { jsx } from 'theme-ui'
3
+ import { useState, useEffect } from 'react'
4
+ import { useInView } from 'react-intersection-observer'
5
+
6
+ const DefaultPlaceholder = ({ height = '100%', width = '100%' }) => (
7
+ <div
8
+ data-testid='default-placeholder'
9
+ sx={{
10
+ minHeight: '1px',
11
+ minWidth: '1px',
12
+ height,
13
+ width
14
+ }}
15
+ >
16
+ {' '}
17
+ </div>
18
+ )
19
+
20
+ /**
21
+ * Lazy Loader
22
+ *
23
+ * Hides a component until it's been visible in the viewport.
24
+ */
25
+ const LazyLoad = ({ children, placeholder = <DefaultPlaceholder /> }) => {
26
+ const [hasBeenVisible, setHasBeenVisible] = useState(false)
27
+ const { ref, inView } = useInView({
28
+ triggerOnce: true,
29
+ threshold: 0
30
+ })
31
+
32
+ useEffect(() => {
33
+ if (inView && !hasBeenVisible) {
34
+ setHasBeenVisible(true)
35
+ }
36
+ }, [inView, hasBeenVisible])
37
+
38
+ return <div ref={ref}>{hasBeenVisible ? children : placeholder}</div>
39
+ }
40
+
41
+ export default LazyLoad
@@ -0,0 +1,88 @@
1
+ import React from 'react'
2
+ import { render, screen } from '@testing-library/react'
3
+ import '@testing-library/jest-dom'
4
+ import LazyLoad from './lazy-load.js'
5
+
6
+ let mockInView = false
7
+
8
+ jest.mock('react-intersection-observer', () => ({
9
+ useInView: () => ({
10
+ ref: jest.fn(),
11
+ inView: mockInView
12
+ })
13
+ }))
14
+
15
+ const MockPlaceholder = () => <div data-testid='placeholder'>Placeholder</div>
16
+
17
+ describe('LazyLoad', () => {
18
+ afterEach(() => {
19
+ jest.clearAllMocks()
20
+ mockInView = false
21
+ })
22
+
23
+ it('renders the default placeholder initially', () => {
24
+ render(
25
+ <LazyLoad>
26
+ <div data-testid='content'>Lazy Loaded Content</div>
27
+ </LazyLoad>
28
+ )
29
+
30
+ expect(screen.getByTestId('default-placeholder')).toBeInTheDocument()
31
+ expect(screen.queryByTestId('content')).not.toBeInTheDocument()
32
+ })
33
+
34
+ it('renders the children when visible', () => {
35
+ mockInView = true
36
+
37
+ render(
38
+ <LazyLoad>
39
+ <div data-testid='content'>Lazy Loaded Content</div>
40
+ </LazyLoad>
41
+ )
42
+
43
+ expect(screen.getByTestId('content')).toBeInTheDocument()
44
+ })
45
+
46
+ it('renders the custom placeholder when provided', () => {
47
+ render(
48
+ <LazyLoad placeholder={<MockPlaceholder />}>
49
+ <div data-testid='content'>Lazy Loaded Content</div>
50
+ </LazyLoad>
51
+ )
52
+
53
+ expect(screen.getByTestId('placeholder')).toBeInTheDocument()
54
+ expect(screen.queryByTestId('content')).not.toBeInTheDocument()
55
+ })
56
+
57
+ it('does not re-render children if already visible', () => {
58
+ mockInView = true
59
+
60
+ const { rerender } = render(
61
+ <LazyLoad>
62
+ <div data-testid='content'>Lazy Loaded Content</div>
63
+ </LazyLoad>
64
+ )
65
+
66
+ expect(screen.getByTestId('content')).toBeInTheDocument()
67
+
68
+ mockInView = false
69
+
70
+ rerender(
71
+ <LazyLoad>
72
+ <div data-testid='content'>Lazy Loaded Content</div>
73
+ </LazyLoad>
74
+ )
75
+
76
+ expect(screen.getByTestId('content')).toBeInTheDocument()
77
+ })
78
+
79
+ it('does not render children when visibility remains false', () => {
80
+ render(
81
+ <LazyLoad>
82
+ <div data-testid='content'>Lazy Loaded Content</div>
83
+ </LazyLoad>
84
+ )
85
+
86
+ expect(screen.queryByTestId('content')).not.toBeInTheDocument()
87
+ })
88
+ })
@@ -0,0 +1,10 @@
1
+ import React from 'react'
2
+ import { Heading } from '@theme-ui/components'
3
+
4
+ const PageHeader = ({ children }) => (
5
+ <Heading as='h1' className='p-name' sx={{ mb: 2, lineHeight: 1.5, fontSize: [6, 'calc(1.25em + 2vw)'] }}>
6
+ {children}
7
+ </Heading>
8
+ )
9
+
10
+ export default PageHeader
@@ -0,0 +1,17 @@
1
+ import React from 'react'
2
+ import { render, screen } from '@testing-library/react'
3
+ import '@testing-library/jest-dom'
4
+ import PageHeader from './page-header.js'
5
+
6
+ describe('PageHeader', () => {
7
+ it('renders correctly with given children', () => {
8
+ render(<PageHeader>Hello, World!</PageHeader>)
9
+ const headingElement = screen.getByRole('heading', { name: /Hello, World!/i })
10
+ expect(headingElement).toBeInTheDocument()
11
+ })
12
+
13
+ it('matches snapshot', () => {
14
+ const { asFragment } = render(<PageHeader>Hello, World!</PageHeader>)
15
+ expect(asFragment()).toMatchSnapshot()
16
+ })
17
+ })
@@ -0,0 +1,106 @@
1
+ /** @jsx jsx */
2
+ import React from 'react'
3
+ import { jsx, useThemeUI } from 'theme-ui'
4
+ import isDarkMode from './helpers/isDarkMode.js'
5
+ import { hexToRgb } from './color-utils.js'
6
+
7
+ const PaginationButton = ({
8
+ children,
9
+ onClick,
10
+ disabled = false,
11
+ active = false,
12
+ variant = 'primary',
13
+ size = 'medium',
14
+ icon,
15
+ ...props
16
+ }) => {
17
+ const { colorMode, theme } = useThemeUI()
18
+ const darkModeActive = isDarkMode(colorMode)
19
+
20
+ const colorVariants = {
21
+ secondary: { light: '#666', dark: '#888' },
22
+ success: { light: '#28a745', dark: '#4CAF50' },
23
+ warning: { light: '#ffc107', dark: '#FF9800' },
24
+ danger: { light: '#dc3545', dark: '#f44336' }
25
+ }
26
+ const isPrimary = variant === 'primary' || !colorVariants[variant]
27
+ const primaryColor = isPrimary
28
+ ? (theme?.colors?.primary ?? '#422EA3')
29
+ : darkModeActive
30
+ ? colorVariants[variant].dark
31
+ : colorVariants[variant].light
32
+ const rgb = isPrimary ? (theme?.colors?.primaryRgb ?? '66, 46, 163') : hexToRgb(primaryColor)
33
+
34
+ const sizeVariants = {
35
+ small: {
36
+ fontSize: ['10px', '11px'],
37
+ padding: '4px 8px',
38
+ minWidth: ['24px', '28px'],
39
+ height: ['24px', '28px'],
40
+ gap: 1
41
+ },
42
+ medium: {
43
+ fontSize: ['11px', '12px'],
44
+ padding: '6px 10px',
45
+ minWidth: ['28px', '32px'],
46
+ height: ['28px', '32px'],
47
+ gap: 1
48
+ },
49
+ large: {
50
+ fontSize: ['12px', '13px'],
51
+ padding: '8px 12px',
52
+ minWidth: ['32px', '36px'],
53
+ height: ['32px', '36px'],
54
+ gap: 2
55
+ }
56
+ }
57
+ const sizeStyles = sizeVariants[size] || sizeVariants.medium
58
+
59
+ const baseStyles = {
60
+ color: active ? 'white' : primaryColor,
61
+ textDecoration: 'none',
62
+ fontWeight: active ? 'bold' : 'medium',
63
+ fontSize: sizeStyles.fontSize,
64
+ display: 'inline-flex',
65
+ alignItems: 'center',
66
+ justifyContent: 'center',
67
+ gap: sizeStyles.gap,
68
+ padding: sizeStyles.padding,
69
+ minWidth: sizeStyles.minWidth,
70
+ height: sizeStyles.height,
71
+ borderRadius: '6px',
72
+ background: active ? primaryColor : `rgba(${rgb}, 0.1)`,
73
+ border: active ? `1px solid ${primaryColor}` : `1px solid rgba(${rgb}, 0.2)`,
74
+ transition: 'all 0.2s ease',
75
+ cursor: disabled ? 'not-allowed' : 'pointer',
76
+ outline: 'none',
77
+ opacity: disabled ? 0.5 : 1,
78
+ '&:hover:not(:disabled)': {
79
+ background: active ? primaryColor : `rgba(${rgb}, 0.2)`,
80
+ textDecoration: 'none',
81
+ transform: 'scale(1.02)'
82
+ },
83
+ '&:focus:not(:disabled)': {
84
+ outline: 'none',
85
+ boxShadow: `0 0 0 2px ${primaryColor}20`
86
+ },
87
+ '&:active:not(:disabled)': {
88
+ transform: 'scale(0.98)'
89
+ }
90
+ }
91
+
92
+ const content = (
93
+ <>
94
+ {children}
95
+ {icon && icon}
96
+ </>
97
+ )
98
+
99
+ return (
100
+ <button type='button' onClick={onClick} disabled={disabled} sx={baseStyles} {...props}>
101
+ {content}
102
+ </button>
103
+ )
104
+ }
105
+
106
+ export default PaginationButton
@@ -0,0 +1,197 @@
1
+ /** @jsx jsx */
2
+ import { jsx } from 'theme-ui'
3
+ import { render, screen, fireEvent } from '@testing-library/react'
4
+ import { ThemeUIProvider } from 'theme-ui'
5
+
6
+ import PaginationButton from './pagination-button.js'
7
+ import { BUTTON_PRIMARY_COLORS } from './color-utils.js'
8
+
9
+ const mockUseThemeUI = jest.fn(() => ({
10
+ colorMode: 'default',
11
+ theme: {
12
+ colors: {
13
+ primary: BUTTON_PRIMARY_COLORS.light,
14
+ primaryRgb: '66, 46, 163'
15
+ }
16
+ }
17
+ }))
18
+
19
+ jest.mock('theme-ui', () => ({
20
+ ...jest.requireActual('theme-ui'),
21
+ useThemeUI: () => mockUseThemeUI()
22
+ }))
23
+
24
+ const mockTheme = {
25
+ colors: {
26
+ primary: BUTTON_PRIMARY_COLORS.light,
27
+ primaryRgb: '66, 46, 163',
28
+ modes: {
29
+ dark: {
30
+ text: '#ffffff',
31
+ background: '#000000'
32
+ }
33
+ }
34
+ }
35
+ }
36
+
37
+ const renderWithProviders = (component, customTheme = null) => {
38
+ const themeToUse = customTheme ?? mockTheme
39
+ return render(<ThemeUIProvider theme={themeToUse}>{component}</ThemeUIProvider>)
40
+ }
41
+
42
+ describe('PaginationButton', () => {
43
+ it('renders as a button', () => {
44
+ renderWithProviders(<PaginationButton>1</PaginationButton>)
45
+
46
+ const button = screen.getByRole('button', { name: /1/i })
47
+ expect(button).toBeInTheDocument()
48
+ expect(button.tagName).toBe('BUTTON')
49
+ })
50
+
51
+ it('handles click events', () => {
52
+ const handleClick = jest.fn()
53
+ renderWithProviders(<PaginationButton onClick={handleClick}>1</PaginationButton>)
54
+
55
+ const button = screen.getByRole('button', { name: /1/i })
56
+ fireEvent.click(button)
57
+
58
+ expect(handleClick).toHaveBeenCalledTimes(1)
59
+ })
60
+
61
+ it('applies active state styles', () => {
62
+ renderWithProviders(<PaginationButton active>1</PaginationButton>)
63
+
64
+ const button = screen.getByRole('button', { name: /1/i })
65
+ expect(button).toHaveStyle({
66
+ color: 'rgb(255, 255, 255)',
67
+ fontWeight: 'bold'
68
+ })
69
+ })
70
+
71
+ it('applies disabled state', () => {
72
+ renderWithProviders(<PaginationButton disabled>1</PaginationButton>)
73
+
74
+ const button = screen.getByRole('button', { name: /1/i })
75
+ expect(button).toBeDisabled()
76
+ expect(button).toHaveStyle({
77
+ opacity: '0.5',
78
+ cursor: 'not-allowed'
79
+ })
80
+ })
81
+
82
+ it('applies primary variant styles by default', () => {
83
+ renderWithProviders(<PaginationButton>1</PaginationButton>)
84
+
85
+ const button = screen.getByRole('button', { name: /1/i })
86
+ expect(button).toHaveStyle({ fontWeight: 'medium' })
87
+ expect(button).toBeInTheDocument()
88
+ })
89
+
90
+ it('applies secondary variant styles', () => {
91
+ renderWithProviders(<PaginationButton variant='secondary'>1</PaginationButton>)
92
+
93
+ const button = screen.getByRole('button', { name: /1/i })
94
+ expect(button).toHaveStyle({
95
+ color: '#666'
96
+ })
97
+ })
98
+
99
+ it('applies small size styles', () => {
100
+ renderWithProviders(<PaginationButton size='small'>1</PaginationButton>)
101
+
102
+ const button = screen.getByRole('button', { name: /1/i })
103
+ expect(button).toHaveStyle({
104
+ fontSize: '10px',
105
+ minWidth: '24px',
106
+ height: '24px'
107
+ })
108
+ })
109
+
110
+ it('applies large size styles', () => {
111
+ renderWithProviders(<PaginationButton size='large'>1</PaginationButton>)
112
+
113
+ const button = screen.getByRole('button', { name: /1/i })
114
+ expect(button).toHaveStyle({
115
+ fontSize: '12px',
116
+ minWidth: '32px',
117
+ height: '32px'
118
+ })
119
+ })
120
+
121
+ it('renders with icon', () => {
122
+ const TestIcon = () => <span data-testid='test-icon'>←</span>
123
+ renderWithProviders(<PaginationButton icon={<TestIcon />}>Prev</PaginationButton>)
124
+
125
+ expect(screen.getByTestId('test-icon')).toBeInTheDocument()
126
+ expect(screen.getByRole('button', { name: /prev/i })).toBeInTheDocument()
127
+ })
128
+
129
+ it('passes through additional props', () => {
130
+ renderWithProviders(
131
+ <PaginationButton data-testid='custom-button' aria-label='Custom label'>
132
+ 1
133
+ </PaginationButton>
134
+ )
135
+
136
+ const button = screen.getByTestId('custom-button')
137
+ expect(button).toHaveAttribute('aria-label', 'Custom label')
138
+ })
139
+
140
+ it('has proper accessibility attributes', () => {
141
+ renderWithProviders(<PaginationButton>1</PaginationButton>)
142
+
143
+ const button = screen.getByRole('button', { name: /1/i })
144
+ expect(button).toHaveAttribute('type', 'button')
145
+ })
146
+
147
+ describe('theme fallbacks', () => {
148
+ beforeEach(() => {
149
+ mockUseThemeUI.mockReturnValue({
150
+ colorMode: 'default',
151
+ theme: {
152
+ colors: {
153
+ primary: BUTTON_PRIMARY_COLORS.light,
154
+ primaryRgb: '66, 46, 163'
155
+ }
156
+ }
157
+ })
158
+ })
159
+
160
+ it('uses fallback primary color when theme.colors.primary is undefined', () => {
161
+ mockUseThemeUI.mockReturnValueOnce({
162
+ colorMode: 'default',
163
+ theme: { colors: {} }
164
+ })
165
+
166
+ renderWithProviders(<PaginationButton>1</PaginationButton>)
167
+
168
+ const button = screen.getByRole('button', { name: /1/i })
169
+ expect(button).toBeInTheDocument()
170
+ expect(button).toHaveStyle({ fontWeight: 'medium' })
171
+ })
172
+
173
+ it('uses fallback primaryRgb when theme.colors.primaryRgb is undefined', () => {
174
+ mockUseThemeUI.mockReturnValueOnce({
175
+ colorMode: 'default',
176
+ theme: { colors: { primary: '#422EA3' } }
177
+ })
178
+
179
+ renderWithProviders(<PaginationButton>1</PaginationButton>)
180
+
181
+ const button = screen.getByRole('button', { name: /1/i })
182
+ expect(button).toBeInTheDocument()
183
+ })
184
+
185
+ it('uses fallback when theme itself is undefined', () => {
186
+ mockUseThemeUI.mockReturnValueOnce({
187
+ colorMode: 'default',
188
+ theme: undefined
189
+ })
190
+
191
+ renderWithProviders(<PaginationButton>1</PaginationButton>)
192
+
193
+ const button = screen.getByRole('button', { name: /1/i })
194
+ expect(button).toBeInTheDocument()
195
+ })
196
+ })
197
+ })
@@ -1,7 +1,7 @@
1
1
  /** @jsx jsx */
2
2
  import { jsx, useThemeUI } from 'theme-ui'
3
3
  import { forwardRef } from 'react'
4
- import isDarkMode from '@chronogrove/ui/is-dark-mode'
4
+ import isDarkMode from '../helpers/isDarkMode.js'
5
5
 
6
6
  const SkipNavLink = forwardRef(function SkipNavLink(
7
7
  { as: Comp = 'a', children = 'Skip to content', contentId = 'skip-nav-content', ...props },
package/babel.config.cjs DELETED
@@ -1,9 +0,0 @@
1
- /**
2
- * Jest transforms @emotion and JSX; mirror theme with classic runtime.
3
- */
4
- module.exports = {
5
- presets: [
6
- [require.resolve('@babel/preset-env'), { targets: { node: 'current' } }],
7
- [require.resolve('@babel/preset-react'), { runtime: 'classic', useBuiltIns: true }]
8
- ]
9
- }
package/jest.config.cjs DELETED
@@ -1,33 +0,0 @@
1
- /** @type {import('jest').Config} */
2
- module.exports = {
3
- testEnvironment: 'jsdom',
4
- clearMocks: true,
5
- setupFilesAfterEnv: ['<rootDir>/jest.setup.cjs'],
6
- testMatch: ['**/?(*.)+(spec|test).js'],
7
- transform: {
8
- '^.+\\.jsx?$': 'babel-jest'
9
- },
10
- transformIgnorePatterns: ['node_modules/(?!theme-ui)'],
11
- collectCoverageFrom: ['src/**/*.js', '!src/**/*.spec.js'],
12
- coverageDirectory: 'coverage',
13
- coveragePathIgnorePatterns: [
14
- '/node_modules/',
15
- 'src/index.js',
16
- 'src/skip-nav/index.js',
17
- 'src/color-mode/index.js',
18
- 'src/gatsby/index.js',
19
- 'src/theme.js'
20
- ],
21
- moduleNameMapper: {
22
- '^@chronogrove/ui/is-dark-mode$': '<rootDir>/src/helpers/isDarkMode.js',
23
- '^@theme-toggles/react$': '<rootDir>/test-utils/mock-theme-toggles-react.js'
24
- },
25
- coverageThreshold: {
26
- global: {
27
- statements: 95,
28
- branches: 90,
29
- functions: 95,
30
- lines: 95
31
- }
32
- }
33
- }
package/jest.setup.cjs DELETED
@@ -1 +0,0 @@
1
- require('@testing-library/jest-dom')
@@ -1,10 +0,0 @@
1
- import React from 'react'
2
-
3
- /** Test double for @theme-toggles/react (Jest moduleNameMapper); keeps ColorToggle unit tests offline. */
4
- export function Expand({ toggled, toggle, ...rest }) {
5
- return (
6
- <button type='button' data-toggled={String(toggled)} onClick={toggle} {...rest}>
7
- toggle
8
- </button>
9
- )
10
- }
package/turbo.json DELETED
@@ -1,12 +0,0 @@
1
- {
2
- "$schema": "https://turbo.build/schema.json",
3
- "extends": ["//"],
4
- "tasks": {
5
- "test": {
6
- "outputs": []
7
- },
8
- "test:coverage": {
9
- "outputs": ["coverage/**"]
10
- }
11
- }
12
- }