@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.
- package/README.md +8 -3
- package/package.json +46 -4
- package/src/__snapshots__/profile-metrics-badge.spec.js.snap +20 -0
- package/src/__snapshots__/theme.spec.js.snap +25 -5
- package/src/__snapshots__/widget-header.spec.js.snap +33 -0
- package/src/category-label.js +23 -0
- package/src/category-label.spec.js +24 -0
- package/src/chevron-icons.js +37 -0
- package/src/external-link-icon.js +30 -0
- package/src/external-link-icon.spec.js +16 -0
- package/src/metric-badge.js +10 -0
- package/src/metric-badge.spec.js +15 -0
- package/src/metric-card.js +96 -0
- package/src/metric-card.spec.js +69 -0
- package/src/muted-card-footer.js +22 -0
- package/src/muted-card-footer.spec.js +25 -0
- package/src/pagination.js +198 -0
- package/src/pagination.spec.js +281 -0
- package/src/profile-metrics-badge.js +32 -0
- package/src/profile-metrics-badge.spec.js +89 -0
- package/src/status-card.js +18 -0
- package/src/status-card.spec.js +38 -0
- package/src/theme.js +15 -5
- package/src/widget-call-to-action.js +106 -0
- package/src/widget-call-to-action.spec.js +115 -0
- package/src/widget-header.js +93 -0
- package/src/widget-header.spec.js +76 -0
- package/src/widget-section.js +83 -0
- package/src/widget-section.spec.js +59 -0
- package/src/page-backdrop.js +0 -42
- package/src/page-backdrop.spec.js +0 -41
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box } from '@theme-ui/components'
|
|
3
|
+
|
|
4
|
+
import PaginationButton from './pagination-button.js'
|
|
5
|
+
import { ChevronLeftIcon, ChevronRightIcon } from './chevron-icons.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Full pagination control (prev/next + page numbers, or simple mode).
|
|
9
|
+
* Default prev/next icons are inline SVGs; pass `prevIcon` / `nextIcon` to override.
|
|
10
|
+
*/
|
|
11
|
+
const Pagination = ({
|
|
12
|
+
currentPage,
|
|
13
|
+
totalPages,
|
|
14
|
+
onPageChange,
|
|
15
|
+
variant = 'primary',
|
|
16
|
+
size = 'medium',
|
|
17
|
+
showPageInfo = true,
|
|
18
|
+
maxVisiblePages = 5,
|
|
19
|
+
simple = false,
|
|
20
|
+
prevIcon = <ChevronLeftIcon />,
|
|
21
|
+
nextIcon = <ChevronRightIcon />
|
|
22
|
+
}) => {
|
|
23
|
+
const goToPage = page => {
|
|
24
|
+
if (page >= 1 && page <= totalPages && page !== currentPage) {
|
|
25
|
+
onPageChange(page)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const goToPrevious = () => goToPage(currentPage - 1)
|
|
30
|
+
const goToNext = () => goToPage(currentPage + 1)
|
|
31
|
+
|
|
32
|
+
const getVisiblePages = () => {
|
|
33
|
+
const pages = []
|
|
34
|
+
const halfVisible = Math.floor(maxVisiblePages / 2)
|
|
35
|
+
|
|
36
|
+
let startPage = Math.max(1, currentPage - halfVisible)
|
|
37
|
+
let endPage = Math.min(totalPages, currentPage + halfVisible)
|
|
38
|
+
|
|
39
|
+
if (endPage - startPage + 1 < maxVisiblePages) {
|
|
40
|
+
if (startPage === 1) {
|
|
41
|
+
endPage = Math.min(totalPages, startPage + maxVisiblePages - 1)
|
|
42
|
+
} else {
|
|
43
|
+
startPage = Math.max(1, endPage - maxVisiblePages + 1)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (let i = startPage; i <= endPage; i++) {
|
|
48
|
+
pages.push(i)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return pages
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (totalPages <= 1) {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const visiblePages = getVisiblePages()
|
|
59
|
+
|
|
60
|
+
if (simple) {
|
|
61
|
+
return (
|
|
62
|
+
<Box sx={{ width: '100%' }}>
|
|
63
|
+
<Box
|
|
64
|
+
sx={{
|
|
65
|
+
display: 'flex',
|
|
66
|
+
justifyContent: 'center',
|
|
67
|
+
alignItems: 'center',
|
|
68
|
+
gap: 1.5
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
<PaginationButton
|
|
72
|
+
onClick={goToPrevious}
|
|
73
|
+
disabled={currentPage === 1}
|
|
74
|
+
variant={variant}
|
|
75
|
+
size={size}
|
|
76
|
+
icon={prevIcon}
|
|
77
|
+
aria-label='Previous page'
|
|
78
|
+
/>
|
|
79
|
+
{showPageInfo && (
|
|
80
|
+
<Box as='span' sx={{ fontSize: 0, color: 'textMuted' }}>
|
|
81
|
+
{currentPage}/{totalPages}
|
|
82
|
+
</Box>
|
|
83
|
+
)}
|
|
84
|
+
<PaginationButton
|
|
85
|
+
onClick={goToNext}
|
|
86
|
+
disabled={currentPage === totalPages}
|
|
87
|
+
variant={variant}
|
|
88
|
+
size={size}
|
|
89
|
+
icon={nextIcon}
|
|
90
|
+
aria-label='Next page'
|
|
91
|
+
/>
|
|
92
|
+
</Box>
|
|
93
|
+
</Box>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Box sx={{ width: '100%' }}>
|
|
99
|
+
<Box
|
|
100
|
+
sx={{
|
|
101
|
+
display: 'flex',
|
|
102
|
+
justifyContent: 'center',
|
|
103
|
+
alignItems: 'center',
|
|
104
|
+
mt: 4,
|
|
105
|
+
gap: [1, 2]
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
<PaginationButton
|
|
109
|
+
onClick={goToPrevious}
|
|
110
|
+
disabled={currentPage === 1}
|
|
111
|
+
variant={variant}
|
|
112
|
+
size={size}
|
|
113
|
+
icon={prevIcon}
|
|
114
|
+
aria-label='Previous page'
|
|
115
|
+
/>
|
|
116
|
+
|
|
117
|
+
<Box
|
|
118
|
+
sx={{
|
|
119
|
+
display: 'flex',
|
|
120
|
+
alignItems: 'center',
|
|
121
|
+
gap: 1,
|
|
122
|
+
mx: 2
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{visiblePages[0] > 1 && (
|
|
126
|
+
<>
|
|
127
|
+
<PaginationButton onClick={() => goToPage(1)} variant={variant} size={size} aria-label='Go to page 1'>
|
|
128
|
+
1
|
|
129
|
+
</PaginationButton>
|
|
130
|
+
{visiblePages[0] > 2 && (
|
|
131
|
+
<Box as='span' sx={{ color: 'textMuted', fontSize: '12px', px: 1 }}>
|
|
132
|
+
...
|
|
133
|
+
</Box>
|
|
134
|
+
)}
|
|
135
|
+
</>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{visiblePages.map(page => (
|
|
139
|
+
<PaginationButton
|
|
140
|
+
key={page}
|
|
141
|
+
onClick={() => goToPage(page)}
|
|
142
|
+
active={page === currentPage}
|
|
143
|
+
variant={variant}
|
|
144
|
+
size={size}
|
|
145
|
+
aria-label={`Go to page ${page}`}
|
|
146
|
+
aria-current={page === currentPage ? 'page' : undefined}
|
|
147
|
+
>
|
|
148
|
+
{page}
|
|
149
|
+
</PaginationButton>
|
|
150
|
+
))}
|
|
151
|
+
|
|
152
|
+
{visiblePages[visiblePages.length - 1] < totalPages && (
|
|
153
|
+
<>
|
|
154
|
+
{visiblePages[visiblePages.length - 1] < totalPages - 1 && (
|
|
155
|
+
<Box as='span' sx={{ color: 'textMuted', fontSize: '12px', px: 1 }}>
|
|
156
|
+
...
|
|
157
|
+
</Box>
|
|
158
|
+
)}
|
|
159
|
+
<PaginationButton
|
|
160
|
+
onClick={() => goToPage(totalPages)}
|
|
161
|
+
variant={variant}
|
|
162
|
+
size={size}
|
|
163
|
+
aria-label={`Go to page ${totalPages}`}
|
|
164
|
+
>
|
|
165
|
+
{totalPages}
|
|
166
|
+
</PaginationButton>
|
|
167
|
+
</>
|
|
168
|
+
)}
|
|
169
|
+
</Box>
|
|
170
|
+
|
|
171
|
+
<PaginationButton
|
|
172
|
+
onClick={goToNext}
|
|
173
|
+
disabled={currentPage === totalPages}
|
|
174
|
+
variant={variant}
|
|
175
|
+
size={size}
|
|
176
|
+
icon={nextIcon}
|
|
177
|
+
aria-label='Next page'
|
|
178
|
+
/>
|
|
179
|
+
</Box>
|
|
180
|
+
|
|
181
|
+
{showPageInfo && (
|
|
182
|
+
<Box
|
|
183
|
+
sx={{
|
|
184
|
+
textAlign: 'center',
|
|
185
|
+
mt: 2,
|
|
186
|
+
fontSize: 0,
|
|
187
|
+
color: 'textMuted',
|
|
188
|
+
display: 'block'
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
Page {currentPage} of {totalPages}
|
|
192
|
+
</Box>
|
|
193
|
+
)}
|
|
194
|
+
</Box>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export default Pagination
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { ThemeUIProvider } from 'theme-ui'
|
|
3
|
+
|
|
4
|
+
import Pagination from './pagination.js'
|
|
5
|
+
|
|
6
|
+
const mockTheme = {
|
|
7
|
+
colors: {
|
|
8
|
+
primary: '#422EA3',
|
|
9
|
+
primaryRgb: '66, 46, 163',
|
|
10
|
+
modes: {
|
|
11
|
+
dark: {
|
|
12
|
+
text: '#ffffff',
|
|
13
|
+
background: '#000000'
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const renderWithProviders = component => {
|
|
20
|
+
return render(<ThemeUIProvider theme={mockTheme}>{component}</ThemeUIProvider>)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('Pagination', () => {
|
|
24
|
+
const defaultProps = {
|
|
25
|
+
currentPage: 1,
|
|
26
|
+
totalPages: 5,
|
|
27
|
+
onPageChange: jest.fn()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
jest.clearAllMocks()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('renders pagination controls', () => {
|
|
35
|
+
renderWithProviders(<Pagination {...defaultProps} />)
|
|
36
|
+
|
|
37
|
+
expect(screen.getByLabelText('Previous page')).toBeInTheDocument()
|
|
38
|
+
expect(screen.getByLabelText('Next page')).toBeInTheDocument()
|
|
39
|
+
expect(screen.getByText('1')).toBeInTheDocument()
|
|
40
|
+
expect(screen.getByText('2')).toBeInTheDocument()
|
|
41
|
+
expect(screen.getByText('3')).toBeInTheDocument()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('renders page info', () => {
|
|
45
|
+
renderWithProviders(<Pagination {...defaultProps} />)
|
|
46
|
+
|
|
47
|
+
expect(screen.getByText('Page 1 of 5')).toBeInTheDocument()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('hides page info when showPageInfo is false', () => {
|
|
51
|
+
renderWithProviders(<Pagination {...defaultProps} showPageInfo={false} />)
|
|
52
|
+
|
|
53
|
+
expect(screen.queryByText('Page 1 of 5')).not.toBeInTheDocument()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('handles page changes', () => {
|
|
57
|
+
renderWithProviders(<Pagination {...defaultProps} />)
|
|
58
|
+
|
|
59
|
+
const page2Button = screen.getByText('2')
|
|
60
|
+
fireEvent.click(page2Button)
|
|
61
|
+
|
|
62
|
+
expect(defaultProps.onPageChange).toHaveBeenCalledWith(2)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('does not call onPageChange when clicking the already active page', () => {
|
|
66
|
+
renderWithProviders(<Pagination {...defaultProps} currentPage={2} />)
|
|
67
|
+
jest.clearAllMocks()
|
|
68
|
+
fireEvent.click(screen.getByText('2'))
|
|
69
|
+
expect(defaultProps.onPageChange).not.toHaveBeenCalled()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('handles previous button click', () => {
|
|
73
|
+
renderWithProviders(<Pagination {...defaultProps} currentPage={2} />)
|
|
74
|
+
|
|
75
|
+
const prevButton = screen.getByLabelText('Previous page')
|
|
76
|
+
fireEvent.click(prevButton)
|
|
77
|
+
|
|
78
|
+
expect(defaultProps.onPageChange).toHaveBeenCalledWith(1)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('handles next button click', () => {
|
|
82
|
+
renderWithProviders(<Pagination {...defaultProps} />)
|
|
83
|
+
|
|
84
|
+
const nextButton = screen.getByLabelText('Next page')
|
|
85
|
+
fireEvent.click(nextButton)
|
|
86
|
+
|
|
87
|
+
expect(defaultProps.onPageChange).toHaveBeenCalledWith(2)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('disables previous button on first page', () => {
|
|
91
|
+
renderWithProviders(<Pagination {...defaultProps} currentPage={1} />)
|
|
92
|
+
|
|
93
|
+
const prevButton = screen.getByLabelText('Previous page')
|
|
94
|
+
expect(prevButton).toBeDisabled()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('disables next button on last page', () => {
|
|
98
|
+
renderWithProviders(<Pagination {...defaultProps} currentPage={5} />)
|
|
99
|
+
|
|
100
|
+
const nextButton = screen.getByLabelText('Next page')
|
|
101
|
+
expect(nextButton).toBeDisabled()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('highlights current page', () => {
|
|
105
|
+
renderWithProviders(<Pagination {...defaultProps} currentPage={3} />)
|
|
106
|
+
|
|
107
|
+
const currentPageButton = screen.getByText('3')
|
|
108
|
+
expect(currentPageButton).toHaveAttribute('aria-current', 'page')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('shows ellipsis for large page ranges', () => {
|
|
112
|
+
renderWithProviders(<Pagination {...defaultProps} totalPages={10} currentPage={5} maxVisiblePages={3} />)
|
|
113
|
+
|
|
114
|
+
expect(screen.getAllByText('...')).toHaveLength(2)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('shows first and last pages when needed', () => {
|
|
118
|
+
renderWithProviders(<Pagination {...defaultProps} totalPages={10} currentPage={8} maxVisiblePages={3} />)
|
|
119
|
+
|
|
120
|
+
expect(screen.getByText('1')).toBeInTheDocument()
|
|
121
|
+
expect(screen.getByText('10')).toBeInTheDocument()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('returns null when totalPages is 1', () => {
|
|
125
|
+
const { container } = renderWithProviders(<Pagination {...defaultProps} totalPages={1} />)
|
|
126
|
+
|
|
127
|
+
expect(container.firstChild).toBeNull()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('returns null when totalPages is 0', () => {
|
|
131
|
+
const { container } = renderWithProviders(<Pagination {...defaultProps} totalPages={0} />)
|
|
132
|
+
|
|
133
|
+
expect(container.firstChild).toBeNull()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('applies custom variant', () => {
|
|
137
|
+
renderWithProviders(<Pagination {...defaultProps} variant='secondary' currentPage={2} />)
|
|
138
|
+
|
|
139
|
+
const pageButton = screen.getByText('1')
|
|
140
|
+
expect(pageButton).toHaveStyle({
|
|
141
|
+
color: 'rgb(102, 102, 102)'
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('applies custom size', () => {
|
|
146
|
+
renderWithProviders(<Pagination {...defaultProps} size='large' />)
|
|
147
|
+
|
|
148
|
+
const pageButton = screen.getByText('1')
|
|
149
|
+
expect(pageButton).toHaveStyle({
|
|
150
|
+
fontSize: '12px',
|
|
151
|
+
minWidth: '32px',
|
|
152
|
+
height: '32px'
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('shows ellipsis when gap is exactly 2 pages from start', () => {
|
|
157
|
+
renderWithProviders(<Pagination {...defaultProps} totalPages={10} currentPage={4} maxVisiblePages={3} />)
|
|
158
|
+
|
|
159
|
+
const ellipsis = screen.getAllByText('...')
|
|
160
|
+
expect(ellipsis.length).toBeGreaterThan(0)
|
|
161
|
+
expect(screen.getByText('1')).toBeInTheDocument()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('shows ellipsis when gap is exactly 2 pages from end', () => {
|
|
165
|
+
renderWithProviders(<Pagination {...defaultProps} totalPages={10} currentPage={7} maxVisiblePages={3} />)
|
|
166
|
+
|
|
167
|
+
const ellipsis = screen.getAllByText('...')
|
|
168
|
+
expect(ellipsis.length).toBeGreaterThan(0)
|
|
169
|
+
expect(screen.getByText('10')).toBeInTheDocument()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('handles clicking first page button when first page is not visible', () => {
|
|
173
|
+
renderWithProviders(<Pagination {...defaultProps} totalPages={10} currentPage={5} maxVisiblePages={3} />)
|
|
174
|
+
|
|
175
|
+
const firstPageButton = screen.getByLabelText('Go to page 1')
|
|
176
|
+
fireEvent.click(firstPageButton)
|
|
177
|
+
|
|
178
|
+
expect(defaultProps.onPageChange).toHaveBeenCalledWith(1)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('handles clicking last page button when last page is not visible', () => {
|
|
182
|
+
renderWithProviders(<Pagination {...defaultProps} totalPages={10} currentPage={5} maxVisiblePages={3} />)
|
|
183
|
+
|
|
184
|
+
const lastPageButton = screen.getByLabelText('Go to page 10')
|
|
185
|
+
fireEvent.click(lastPageButton)
|
|
186
|
+
|
|
187
|
+
expect(defaultProps.onPageChange).toHaveBeenCalledWith(10)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('uses custom prev and next icons when provided', () => {
|
|
191
|
+
renderWithProviders(
|
|
192
|
+
<Pagination
|
|
193
|
+
{...defaultProps}
|
|
194
|
+
prevIcon={<span data-testid='prev-custom'>«</span>}
|
|
195
|
+
nextIcon={<span data-testid='next-custom'>»</span>}
|
|
196
|
+
/>
|
|
197
|
+
)
|
|
198
|
+
expect(screen.getByTestId('prev-custom')).toBeInTheDocument()
|
|
199
|
+
expect(screen.getByTestId('next-custom')).toBeInTheDocument()
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('expands page window from the end when the range is narrower than maxVisiblePages', () => {
|
|
203
|
+
renderWithProviders(<Pagination {...defaultProps} totalPages={10} currentPage={10} maxVisiblePages={5} />)
|
|
204
|
+
expect(screen.getByLabelText('Go to page 10')).toBeInTheDocument()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('Simple mode', () => {
|
|
208
|
+
it('renders only prev/next buttons in simple mode', () => {
|
|
209
|
+
renderWithProviders(<Pagination {...defaultProps} simple={true} />)
|
|
210
|
+
|
|
211
|
+
expect(screen.getByLabelText('Previous page')).toBeInTheDocument()
|
|
212
|
+
expect(screen.getByLabelText('Next page')).toBeInTheDocument()
|
|
213
|
+
expect(screen.queryByText('1')).not.toBeInTheDocument()
|
|
214
|
+
expect(screen.queryByText('2')).not.toBeInTheDocument()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('shows page counter in simple mode when showPageInfo is true', () => {
|
|
218
|
+
renderWithProviders(<Pagination {...defaultProps} simple={true} showPageInfo={true} />)
|
|
219
|
+
|
|
220
|
+
expect(screen.getByText('1/5')).toBeInTheDocument()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('hides page counter in simple mode when showPageInfo is false', () => {
|
|
224
|
+
renderWithProviders(<Pagination {...defaultProps} simple={true} showPageInfo={false} />)
|
|
225
|
+
|
|
226
|
+
expect(screen.queryByText('1/5')).not.toBeInTheDocument()
|
|
227
|
+
expect(screen.getByLabelText('Previous page')).toBeInTheDocument()
|
|
228
|
+
expect(screen.getByLabelText('Next page')).toBeInTheDocument()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('handles previous button click in simple mode', () => {
|
|
232
|
+
renderWithProviders(<Pagination {...defaultProps} simple={true} currentPage={2} />)
|
|
233
|
+
|
|
234
|
+
const prevButton = screen.getByLabelText('Previous page')
|
|
235
|
+
fireEvent.click(prevButton)
|
|
236
|
+
|
|
237
|
+
expect(defaultProps.onPageChange).toHaveBeenCalledWith(1)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('handles next button click in simple mode', () => {
|
|
241
|
+
renderWithProviders(<Pagination {...defaultProps} simple={true} />)
|
|
242
|
+
|
|
243
|
+
const nextButton = screen.getByLabelText('Next page')
|
|
244
|
+
fireEvent.click(nextButton)
|
|
245
|
+
|
|
246
|
+
expect(defaultProps.onPageChange).toHaveBeenCalledWith(2)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('disables previous button on first page in simple mode', () => {
|
|
250
|
+
renderWithProviders(<Pagination {...defaultProps} simple={true} currentPage={1} />)
|
|
251
|
+
|
|
252
|
+
const prevButton = screen.getByLabelText('Previous page')
|
|
253
|
+
expect(prevButton).toBeDisabled()
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('disables next button on last page in simple mode', () => {
|
|
257
|
+
renderWithProviders(<Pagination {...defaultProps} simple={true} currentPage={5} />)
|
|
258
|
+
|
|
259
|
+
const nextButton = screen.getByLabelText('Next page')
|
|
260
|
+
expect(nextButton).toBeDisabled()
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('applies custom variant in simple mode', () => {
|
|
264
|
+
renderWithProviders(<Pagination {...defaultProps} simple={true} variant='secondary' />)
|
|
265
|
+
|
|
266
|
+
expect(screen.getByLabelText('Previous page')).toBeInTheDocument()
|
|
267
|
+
expect(screen.getByLabelText('Next page')).toBeInTheDocument()
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('applies custom size in simple mode', () => {
|
|
271
|
+
renderWithProviders(<Pagination {...defaultProps} simple={true} size='small' />)
|
|
272
|
+
|
|
273
|
+
const prevButton = screen.getByLabelText('Previous page')
|
|
274
|
+
expect(prevButton).toHaveStyle({
|
|
275
|
+
fontSize: '10px',
|
|
276
|
+
minWidth: '24px',
|
|
277
|
+
height: '24px'
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Badge, Box } from '@theme-ui/components'
|
|
3
|
+
|
|
4
|
+
const ProfileMetricsBadge = ({ compact = false, isLoading, metrics = [] }) => {
|
|
5
|
+
let metricsToShow
|
|
6
|
+
if (isLoading) {
|
|
7
|
+
metricsToShow = [{}, {}]
|
|
8
|
+
} else if (Array.isArray(metrics)) {
|
|
9
|
+
metricsToShow = metrics
|
|
10
|
+
} else {
|
|
11
|
+
metricsToShow = []
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<Box
|
|
16
|
+
sx={{
|
|
17
|
+
fontFamily: 'heading',
|
|
18
|
+
...(compact ? { mt: 0, pb: 0, pt: 0 } : { mt: 2, pb: 4, pt: 1 }),
|
|
19
|
+
display: 'flex',
|
|
20
|
+
justifyContent: ['center', 'unset']
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
{metricsToShow.map(({ displayName, id, value }, idx) => (
|
|
24
|
+
<Badge key={id || idx} variant='metrics' ml={idx !== 0 && 2}>
|
|
25
|
+
{value} {displayName}
|
|
26
|
+
</Badge>
|
|
27
|
+
))}
|
|
28
|
+
</Box>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default ProfileMetricsBadge
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { ThemeUIProvider } from 'theme-ui'
|
|
3
|
+
|
|
4
|
+
import chronogroveTheme from './theme.js'
|
|
5
|
+
import ProfileMetricsBadge from './profile-metrics-badge.js'
|
|
6
|
+
|
|
7
|
+
describe('ProfileMetricsBadge', () => {
|
|
8
|
+
it('uses default props when none are passed', () => {
|
|
9
|
+
const { container } = render(
|
|
10
|
+
<ThemeUIProvider theme={chronogroveTheme}>
|
|
11
|
+
<ProfileMetricsBadge />
|
|
12
|
+
</ThemeUIProvider>
|
|
13
|
+
)
|
|
14
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('matches the snapshot with metrics', () => {
|
|
18
|
+
const metrics = [
|
|
19
|
+
{ displayName: 'Followers', id: 'followers', value: 10 },
|
|
20
|
+
{ displayName: 'Following', id: 'following', value: 20 }
|
|
21
|
+
]
|
|
22
|
+
const { asFragment } = render(
|
|
23
|
+
<ThemeUIProvider theme={chronogroveTheme}>
|
|
24
|
+
<ProfileMetricsBadge metrics={metrics} />
|
|
25
|
+
</ThemeUIProvider>
|
|
26
|
+
)
|
|
27
|
+
expect(asFragment()).toMatchSnapshot()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('renders metrics with value and displayName', () => {
|
|
31
|
+
const metrics = [{ displayName: 'Friends', id: 'friends', value: 5 }]
|
|
32
|
+
render(
|
|
33
|
+
<ThemeUIProvider theme={chronogroveTheme}>
|
|
34
|
+
<ProfileMetricsBadge metrics={metrics} />
|
|
35
|
+
</ThemeUIProvider>
|
|
36
|
+
)
|
|
37
|
+
expect(screen.getByText('5 Friends')).toBeInTheDocument()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('uses compact spacing when compact is true', () => {
|
|
41
|
+
const metrics = [{ displayName: 'Count', id: 'count', value: 1 }]
|
|
42
|
+
const { container } = render(
|
|
43
|
+
<ThemeUIProvider theme={chronogroveTheme}>
|
|
44
|
+
<ProfileMetricsBadge compact metrics={metrics} />
|
|
45
|
+
</ThemeUIProvider>
|
|
46
|
+
)
|
|
47
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
48
|
+
expect(screen.getByText('1 Count')).toBeInTheDocument()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('shows placeholder badges when isLoading', () => {
|
|
52
|
+
const { container } = render(
|
|
53
|
+
<ThemeUIProvider theme={chronogroveTheme}>
|
|
54
|
+
<ProfileMetricsBadge isLoading metrics={[]} />
|
|
55
|
+
</ThemeUIProvider>
|
|
56
|
+
)
|
|
57
|
+
const wrapper = container.querySelector('[class*="css-"]')
|
|
58
|
+
expect(wrapper).toBeInTheDocument()
|
|
59
|
+
expect(wrapper.children).toHaveLength(2)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('handles non-array metrics with default empty array', () => {
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<ThemeUIProvider theme={chronogroveTheme}>
|
|
65
|
+
<ProfileMetricsBadge metrics={null} />
|
|
66
|
+
</ThemeUIProvider>
|
|
67
|
+
)
|
|
68
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('treats non-array metrics as empty when not loading', () => {
|
|
72
|
+
const { container } = render(
|
|
73
|
+
<ThemeUIProvider theme={chronogroveTheme}>
|
|
74
|
+
<ProfileMetricsBadge metrics='nope' />
|
|
75
|
+
</ThemeUIProvider>
|
|
76
|
+
)
|
|
77
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
78
|
+
expect(container.textContent).toBe('')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('renders nothing when metrics is an empty array', () => {
|
|
82
|
+
const { container } = render(
|
|
83
|
+
<ThemeUIProvider theme={chronogroveTheme}>
|
|
84
|
+
<ProfileMetricsBadge metrics={[]} />
|
|
85
|
+
</ThemeUIProvider>
|
|
86
|
+
)
|
|
87
|
+
expect(container.textContent).toBe('')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Card } from '@theme-ui/components'
|
|
3
|
+
import { useThemeUI } from 'theme-ui'
|
|
4
|
+
|
|
5
|
+
import isDarkMode from './helpers/isDarkMode.js'
|
|
6
|
+
|
|
7
|
+
const StatusCard = ({ message, ...props }) => {
|
|
8
|
+
const { colorMode } = useThemeUI()
|
|
9
|
+
const variant = isDarkMode(colorMode) ? 'metricCardDark' : 'metricCard'
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<Card variant={variant} sx={{ mb: 3 }} {...props}>
|
|
13
|
+
{message}
|
|
14
|
+
</Card>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default StatusCard
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { ThemeUIProvider } from 'theme-ui'
|
|
3
|
+
|
|
4
|
+
import StatusCard from './status-card.js'
|
|
5
|
+
|
|
6
|
+
jest.mock('theme-ui', () => {
|
|
7
|
+
const actual = jest.requireActual('theme-ui')
|
|
8
|
+
return {
|
|
9
|
+
...actual,
|
|
10
|
+
useThemeUI: jest.fn(() => ({ colorMode: 'default' }))
|
|
11
|
+
}
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const { useThemeUI } = require('theme-ui')
|
|
15
|
+
|
|
16
|
+
const theme = {}
|
|
17
|
+
|
|
18
|
+
describe('StatusCard', () => {
|
|
19
|
+
it('renders message in light mode variant', () => {
|
|
20
|
+
useThemeUI.mockReturnValue({ colorMode: 'default' })
|
|
21
|
+
render(
|
|
22
|
+
<ThemeUIProvider theme={theme}>
|
|
23
|
+
<StatusCard message='No data' />
|
|
24
|
+
</ThemeUIProvider>
|
|
25
|
+
)
|
|
26
|
+
expect(screen.getByText('No data')).toBeInTheDocument()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('uses dark card variant when dark', () => {
|
|
30
|
+
useThemeUI.mockReturnValue({ colorMode: 'dark' })
|
|
31
|
+
render(
|
|
32
|
+
<ThemeUIProvider theme={theme}>
|
|
33
|
+
<StatusCard message='Err' />
|
|
34
|
+
</ThemeUIProvider>
|
|
35
|
+
)
|
|
36
|
+
expect(screen.getByText('Err')).toBeInTheDocument()
|
|
37
|
+
})
|
|
38
|
+
})
|
package/src/theme.js
CHANGED
|
@@ -94,11 +94,9 @@ export const metricCard = {
|
|
|
94
94
|
backgroundColor: 'panel-background',
|
|
95
95
|
boxShadow: 'none',
|
|
96
96
|
color: 'text',
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
padding: 2
|
|
101
|
-
}
|
|
97
|
+
/** Stat tiles read clearly on animated / busy page backgrounds */
|
|
98
|
+
border: '1px solid',
|
|
99
|
+
borderColor: 'panel-divider'
|
|
102
100
|
}
|
|
103
101
|
|
|
104
102
|
export const PostCard = {
|
|
@@ -256,6 +254,12 @@ export default merge(tailwind, {
|
|
|
256
254
|
...metricCard
|
|
257
255
|
},
|
|
258
256
|
|
|
257
|
+
metricCardDark: {
|
|
258
|
+
...card,
|
|
259
|
+
...metricCard,
|
|
260
|
+
backgroundColor: '#1e2530'
|
|
261
|
+
},
|
|
262
|
+
|
|
259
263
|
/* The following styles represent specific card components, indicated in PascalCase. */
|
|
260
264
|
|
|
261
265
|
UserProfile: {
|
|
@@ -563,6 +567,12 @@ export default merge(tailwind, {
|
|
|
563
567
|
mt: 2
|
|
564
568
|
},
|
|
565
569
|
|
|
570
|
+
mutedCardFooter: {
|
|
571
|
+
display: 'flex',
|
|
572
|
+
justifyContent: 'space-between',
|
|
573
|
+
mt: 2
|
|
574
|
+
},
|
|
575
|
+
|
|
566
576
|
PageFooter: {
|
|
567
577
|
zIndex: '10',
|
|
568
578
|
color: 'text',
|