@chronogrove/ui 0.80.0 → 0.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +37 -1
- package/src/__snapshots__/theme.spec.js.snap +25 -5
- 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 +95 -0
- package/src/metric-card.spec.js +60 -0
- package/src/muted-card-footer.js +22 -0
- package/src/muted-card-footer.spec.js +25 -0
- package/src/pagination.js +198 -0
- package/src/pagination.spec.js +281 -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-section.js +83 -0
- package/src/widget-section.spec.js +59 -0
|
@@ -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,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',
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box } from '@theme-ui/components'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default loading indicator for widget CTAs (SVG spinner, no extra dependencies).
|
|
6
|
+
*/
|
|
7
|
+
export const WidgetCtaLoadingIndicator = () => (
|
|
8
|
+
<Box
|
|
9
|
+
as='svg'
|
|
10
|
+
role='status'
|
|
11
|
+
aria-label='Loading'
|
|
12
|
+
width='24'
|
|
13
|
+
height='24'
|
|
14
|
+
viewBox='0 0 24 24'
|
|
15
|
+
sx={{
|
|
16
|
+
verticalAlign: 'middle',
|
|
17
|
+
color: 'primary',
|
|
18
|
+
animation: 'spin 0.8s linear infinite',
|
|
19
|
+
'@keyframes spin': { to: { transform: 'rotate(360deg)' } }
|
|
20
|
+
}}
|
|
21
|
+
>
|
|
22
|
+
<circle
|
|
23
|
+
cx='12'
|
|
24
|
+
cy='12'
|
|
25
|
+
r='10'
|
|
26
|
+
fill='none'
|
|
27
|
+
stroke='currentColor'
|
|
28
|
+
strokeWidth='3'
|
|
29
|
+
strokeLinecap='round'
|
|
30
|
+
strokeDasharray='31.4 31.4'
|
|
31
|
+
/>
|
|
32
|
+
</Box>
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const linkSxBase = {
|
|
36
|
+
variant: 'links.widgetCta',
|
|
37
|
+
'.read-more-icon': {
|
|
38
|
+
opacity: 0,
|
|
39
|
+
transition: 'all .3s ease',
|
|
40
|
+
ml: 1
|
|
41
|
+
},
|
|
42
|
+
'&:hover .read-more-icon, &:focus .read-more-icon': {
|
|
43
|
+
opacity: 1
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Text-style CTA used next to widget headlines. Pass `linkComponent` (e.g. Gatsby `Link` with `to`, or Next `Link` with `href`).
|
|
49
|
+
* External links: set `href` or `url` (no `linkComponent`).
|
|
50
|
+
*/
|
|
51
|
+
export const WidgetCallToAction = ({
|
|
52
|
+
children,
|
|
53
|
+
isLoading = false,
|
|
54
|
+
loadingSlot,
|
|
55
|
+
title,
|
|
56
|
+
to,
|
|
57
|
+
url,
|
|
58
|
+
href,
|
|
59
|
+
linkComponent,
|
|
60
|
+
sx: sxProp,
|
|
61
|
+
...rest
|
|
62
|
+
}) => {
|
|
63
|
+
const ctaSx = { ...linkSxBase, ...sxProp }
|
|
64
|
+
|
|
65
|
+
if (isLoading) {
|
|
66
|
+
return loadingSlot ?? <WidgetCtaLoadingIndicator />
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (linkComponent) {
|
|
70
|
+
const L = linkComponent
|
|
71
|
+
// Theme UI `sx` only applies when the outer node is a Theme UI `Box` (or other `sx`-aware
|
|
72
|
+
// primitive). Router links (Next.js `Link`, Gatsby `Link`) do not accept `sx` themselves.
|
|
73
|
+
if (href != null && href !== '') {
|
|
74
|
+
return (
|
|
75
|
+
<Box as={L} href={href} sx={ctaSx} title={title} {...rest}>
|
|
76
|
+
{children}
|
|
77
|
+
</Box>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
if (to) {
|
|
81
|
+
return (
|
|
82
|
+
<Box as={L} to={to} sx={ctaSx} title={title} {...rest}>
|
|
83
|
+
{children}
|
|
84
|
+
</Box>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (to) {
|
|
90
|
+
return (
|
|
91
|
+
<Box as='a' href={to} sx={ctaSx} title={title} {...rest}>
|
|
92
|
+
{children}
|
|
93
|
+
</Box>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const externalHref = href ?? url
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<Box as='a' href={externalHref} sx={ctaSx} title={title} {...rest}>
|
|
101
|
+
{children}
|
|
102
|
+
</Box>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default WidgetCallToAction
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { ThemeUIProvider } from 'theme-ui'
|
|
3
|
+
|
|
4
|
+
import WidgetCallToAction, { WidgetCtaLoadingIndicator } from './widget-call-to-action.js'
|
|
5
|
+
|
|
6
|
+
const LinkMock = ({ to, href, children, ...rest }) => (
|
|
7
|
+
<a href={href ?? to} data-testid='router-link' {...rest}>
|
|
8
|
+
{children}
|
|
9
|
+
</a>
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
const theme = { links: { widgetCta: { color: 'text' } } }
|
|
13
|
+
|
|
14
|
+
const wrap = ui => render(<ThemeUIProvider theme={theme}>{ui}</ThemeUIProvider>)
|
|
15
|
+
|
|
16
|
+
describe('WidgetCtaLoadingIndicator', () => {
|
|
17
|
+
it('renders svg', () => {
|
|
18
|
+
const { container } = wrap(<WidgetCtaLoadingIndicator />)
|
|
19
|
+
expect(container.querySelector('svg')).toBeInTheDocument()
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('WidgetCallToAction', () => {
|
|
24
|
+
it('renders external link with url', () => {
|
|
25
|
+
wrap(
|
|
26
|
+
<WidgetCallToAction title='t' url='https://example.com'>
|
|
27
|
+
Go
|
|
28
|
+
</WidgetCallToAction>
|
|
29
|
+
)
|
|
30
|
+
const a = screen.getByRole('link')
|
|
31
|
+
expect(a).toHaveAttribute('href', 'https://example.com')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('prefers href over url', () => {
|
|
35
|
+
wrap(
|
|
36
|
+
<WidgetCallToAction href='https://a.test' url='https://b.test'>
|
|
37
|
+
Go
|
|
38
|
+
</WidgetCallToAction>
|
|
39
|
+
)
|
|
40
|
+
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://a.test')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('uses linkComponent with to (Gatsby-style)', () => {
|
|
44
|
+
wrap(
|
|
45
|
+
<WidgetCallToAction linkComponent={LinkMock} to='/blog'>
|
|
46
|
+
Posts
|
|
47
|
+
</WidgetCallToAction>
|
|
48
|
+
)
|
|
49
|
+
expect(screen.getByTestId('router-link')).toHaveAttribute('href', '/blog')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('uses linkComponent with href (Next.js-style)', () => {
|
|
53
|
+
wrap(
|
|
54
|
+
<WidgetCallToAction linkComponent={LinkMock} href='/dashboard'>
|
|
55
|
+
Dash
|
|
56
|
+
</WidgetCallToAction>
|
|
57
|
+
)
|
|
58
|
+
expect(screen.getByTestId('router-link')).toHaveAttribute('href', '/dashboard')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('uses linkComponent with to when href is empty', () => {
|
|
62
|
+
wrap(
|
|
63
|
+
<WidgetCallToAction linkComponent={LinkMock} href='' to='/blog'>
|
|
64
|
+
Posts
|
|
65
|
+
</WidgetCallToAction>
|
|
66
|
+
)
|
|
67
|
+
expect(screen.getByTestId('router-link')).toHaveAttribute('href', '/blog')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('ignores linkComponent for external url when no href or to', () => {
|
|
71
|
+
wrap(
|
|
72
|
+
<WidgetCallToAction linkComponent={LinkMock} url='https://example.com/out'>
|
|
73
|
+
Out
|
|
74
|
+
</WidgetCallToAction>
|
|
75
|
+
)
|
|
76
|
+
expect(screen.queryByTestId('router-link')).not.toBeInTheDocument()
|
|
77
|
+
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com/out')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('uses plain anchor when to without linkComponent', () => {
|
|
81
|
+
wrap(
|
|
82
|
+
<WidgetCallToAction to='/path' linkComponent={undefined}>
|
|
83
|
+
P
|
|
84
|
+
</WidgetCallToAction>
|
|
85
|
+
)
|
|
86
|
+
expect(screen.getByRole('link')).toHaveAttribute('href', '/path')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('shows loading indicator', () => {
|
|
90
|
+
const { container } = wrap(
|
|
91
|
+
<WidgetCallToAction isLoading title='t'>
|
|
92
|
+
X
|
|
93
|
+
</WidgetCallToAction>
|
|
94
|
+
)
|
|
95
|
+
expect(container.querySelector('svg')).toBeInTheDocument()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('uses custom loadingSlot', () => {
|
|
99
|
+
wrap(
|
|
100
|
+
<WidgetCallToAction isLoading loadingSlot={<span data-testid='ld'>wait</span>}>
|
|
101
|
+
X
|
|
102
|
+
</WidgetCallToAction>
|
|
103
|
+
)
|
|
104
|
+
expect(screen.getByTestId('ld')).toBeInTheDocument()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('merges sx', () => {
|
|
108
|
+
wrap(
|
|
109
|
+
<WidgetCallToAction href='https://x.test' sx={{ m: 2 }}>
|
|
110
|
+
L
|
|
111
|
+
</WidgetCallToAction>
|
|
112
|
+
)
|
|
113
|
+
expect(screen.getByRole('link')).toBeInTheDocument()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box } from '@theme-ui/components'
|
|
3
|
+
import { useThemeUI } from 'theme-ui'
|
|
4
|
+
|
|
5
|
+
import isDarkMode from './helpers/isDarkMode.js'
|
|
6
|
+
|
|
7
|
+
const sectionSx = {
|
|
8
|
+
mb: 4,
|
|
9
|
+
pt: [0, 3, 4],
|
|
10
|
+
pb: [0, 3, 4]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wraps a dashboard widget: vertical spacing and optional fatal-error overlay.
|
|
15
|
+
*/
|
|
16
|
+
const WidgetSection = ({ children, hasFatalError, id, styleOverrides = {}, ...props }) => {
|
|
17
|
+
const { colorMode } = useThemeUI()
|
|
18
|
+
const darkMode = isDarkMode(colorMode)
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Box
|
|
22
|
+
as='section'
|
|
23
|
+
sx={{
|
|
24
|
+
...sectionSx,
|
|
25
|
+
...styleOverrides,
|
|
26
|
+
...(hasFatalError
|
|
27
|
+
? {
|
|
28
|
+
position: 'relative'
|
|
29
|
+
}
|
|
30
|
+
: {})
|
|
31
|
+
}}
|
|
32
|
+
{...(id ? { id } : {})}
|
|
33
|
+
{...props}
|
|
34
|
+
>
|
|
35
|
+
{hasFatalError && (
|
|
36
|
+
<Box
|
|
37
|
+
sx={{
|
|
38
|
+
alignItems: 'center',
|
|
39
|
+
bottom: 0,
|
|
40
|
+
display: 'flex',
|
|
41
|
+
justifyContent: 'center',
|
|
42
|
+
left: 0,
|
|
43
|
+
position: 'absolute',
|
|
44
|
+
right: 0,
|
|
45
|
+
top: 0
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
<Box
|
|
49
|
+
sx={{
|
|
50
|
+
background: darkMode ? '#252e3c' : 'white',
|
|
51
|
+
borderLeft: '2px solid red',
|
|
52
|
+
borderRight: '2px solid red',
|
|
53
|
+
borderRadius: '2px',
|
|
54
|
+
boxShadow: 'xl',
|
|
55
|
+
py: 3,
|
|
56
|
+
px: 4,
|
|
57
|
+
zIndex: 480
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<h4>Something went wrong</h4>
|
|
61
|
+
<p>Failed to load this widget.</p>
|
|
62
|
+
</Box>
|
|
63
|
+
<Box
|
|
64
|
+
sx={{
|
|
65
|
+
top: 0,
|
|
66
|
+
right: 0,
|
|
67
|
+
bottom: 0,
|
|
68
|
+
left: 0,
|
|
69
|
+
background: darkMode
|
|
70
|
+
? 'radial-gradient(rgba(14.5,18,23.5,0.4) 20%, transparent 50%);'
|
|
71
|
+
: 'radial-gradient(rgba(255, 255, 255, 0.4) 20%, transparent 50%)',
|
|
72
|
+
position: 'absolute',
|
|
73
|
+
zIndex: 470
|
|
74
|
+
}}
|
|
75
|
+
/>
|
|
76
|
+
</Box>
|
|
77
|
+
)}
|
|
78
|
+
{children}
|
|
79
|
+
</Box>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default WidgetSection
|