@chronogrove/ui 0.77.0 → 0.78.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 +18 -11
- package/package.json +26 -1
- package/src/__snapshots__/header.spec.js.snap +69 -0
- package/src/__snapshots__/page-header.spec.js.snap +11 -0
- package/src/action-button.js +83 -0
- package/src/action-button.spec.js +201 -0
- package/src/color-utils.js +37 -0
- package/src/color-utils.spec.js +43 -0
- package/src/header.js +28 -0
- package/src/header.spec.js +47 -0
- package/src/lazy-load.js +41 -0
- package/src/lazy-load.spec.js +88 -0
- package/src/page-header.js +10 -0
- package/src/page-header.spec.js +17 -0
- package/src/pagination-button.js +106 -0
- package/src/pagination-button.spec.js +197 -0
package/README.md
CHANGED
|
@@ -16,17 +16,24 @@ Use **`pnpm publish`** for releases so `workspace:` dependencies in dependents a
|
|
|
16
16
|
|
|
17
17
|
Prefer deep imports so bundles stay lean:
|
|
18
18
|
|
|
19
|
-
| Import path
|
|
20
|
-
|
|
|
21
|
-
| `@chronogrove/ui`
|
|
22
|
-
| `@chronogrove/ui/theme`
|
|
23
|
-
| `@chronogrove/ui/provider`
|
|
24
|
-
| `@chronogrove/ui/color-mode`
|
|
25
|
-
| `@chronogrove/ui/emotion-cache`
|
|
26
|
-
| `@chronogrove/ui/button`
|
|
27
|
-
| `@chronogrove/ui/color-toggle`
|
|
28
|
-
| `@chronogrove/ui/skip-nav`
|
|
29
|
-
| `@chronogrove/ui/is-dark-mode`
|
|
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 |
|
|
30
|
+
| `@chronogrove/ui/color-utils` | `hexToRgb`, `hexToRgba`, `BUTTON_PRIMARY_COLORS` |
|
|
31
|
+
| `@chronogrove/ui/action-button` | Outline CTA as `<button>` or `<a>` |
|
|
32
|
+
| `@chronogrove/ui/pagination-button` | Compact paginator control |
|
|
33
|
+
| `@chronogrove/ui/lazy-load` | Defer children until in viewport (`react-intersection-observer`) |
|
|
34
|
+
| `@chronogrove/ui/header` | Masthead shell (`variant: styles.Header`) |
|
|
35
|
+
| `@chronogrove/ui/page-header` | Blog-style `h1` heading (`p-name`) |
|
|
36
|
+
| `@chronogrove/ui/gatsby` | Color-mode Gatsby SSR/browser helpers |
|
|
30
37
|
|
|
31
38
|
## Next.js (App Router)
|
|
32
39
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronogrove/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.78.0",
|
|
4
4
|
"description": "Chronogrove Theme UI theme, color mode helpers, and shared UI primitives",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -46,6 +46,30 @@
|
|
|
46
46
|
"import": "./src/helpers/isDarkMode.js",
|
|
47
47
|
"default": "./src/helpers/isDarkMode.js"
|
|
48
48
|
},
|
|
49
|
+
"./color-utils": {
|
|
50
|
+
"import": "./src/color-utils.js",
|
|
51
|
+
"default": "./src/color-utils.js"
|
|
52
|
+
},
|
|
53
|
+
"./action-button": {
|
|
54
|
+
"import": "./src/action-button.js",
|
|
55
|
+
"default": "./src/action-button.js"
|
|
56
|
+
},
|
|
57
|
+
"./pagination-button": {
|
|
58
|
+
"import": "./src/pagination-button.js",
|
|
59
|
+
"default": "./src/pagination-button.js"
|
|
60
|
+
},
|
|
61
|
+
"./lazy-load": {
|
|
62
|
+
"import": "./src/lazy-load.js",
|
|
63
|
+
"default": "./src/lazy-load.js"
|
|
64
|
+
},
|
|
65
|
+
"./header": {
|
|
66
|
+
"import": "./src/header.js",
|
|
67
|
+
"default": "./src/header.js"
|
|
68
|
+
},
|
|
69
|
+
"./page-header": {
|
|
70
|
+
"import": "./src/page-header.js",
|
|
71
|
+
"default": "./src/page-header.js"
|
|
72
|
+
},
|
|
49
73
|
"./gatsby": {
|
|
50
74
|
"import": "./src/gatsby/index.js",
|
|
51
75
|
"default": "./src/gatsby/index.js"
|
|
@@ -61,6 +85,7 @@
|
|
|
61
85
|
"@theme-toggles/react": "^4.1.0",
|
|
62
86
|
"@theme-ui/components": "^0.17.4",
|
|
63
87
|
"@theme-ui/presets": "^0.17.4",
|
|
88
|
+
"react-intersection-observer": "^10.0.3",
|
|
64
89
|
"theme-ui": "^0.17.4"
|
|
65
90
|
},
|
|
66
91
|
"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,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
|
+
})
|
|
@@ -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
|
+
})
|
package/src/lazy-load.js
ADDED
|
@@ -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
|
+
})
|