@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chronogrove/ui",
3
- "version": "0.80.0",
3
+ "version": "0.81.0",
4
4
  "description": "Chronogrove Theme UI theme, color mode helpers, and shared UI primitives",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -66,6 +66,42 @@
66
66
  "import": "./src/pagination-button.js",
67
67
  "default": "./src/pagination-button.js"
68
68
  },
69
+ "./pagination": {
70
+ "import": "./src/pagination.js",
71
+ "default": "./src/pagination.js"
72
+ },
73
+ "./category-label": {
74
+ "import": "./src/category-label.js",
75
+ "default": "./src/category-label.js"
76
+ },
77
+ "./external-link-icon": {
78
+ "import": "./src/external-link-icon.js",
79
+ "default": "./src/external-link-icon.js"
80
+ },
81
+ "./metric-badge": {
82
+ "import": "./src/metric-badge.js",
83
+ "default": "./src/metric-badge.js"
84
+ },
85
+ "./metric-card": {
86
+ "import": "./src/metric-card.js",
87
+ "default": "./src/metric-card.js"
88
+ },
89
+ "./muted-card-footer": {
90
+ "import": "./src/muted-card-footer.js",
91
+ "default": "./src/muted-card-footer.js"
92
+ },
93
+ "./status-card": {
94
+ "import": "./src/status-card.js",
95
+ "default": "./src/status-card.js"
96
+ },
97
+ "./widget-section": {
98
+ "import": "./src/widget-section.js",
99
+ "default": "./src/widget-section.js"
100
+ },
101
+ "./widget-call-to-action": {
102
+ "import": "./src/widget-call-to-action.js",
103
+ "default": "./src/widget-call-to-action.js"
104
+ },
69
105
  "./lazy-load": {
70
106
  "import": "./src/lazy-load.js",
71
107
  "default": "./src/lazy-load.js"
@@ -303,6 +303,26 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
303
303
  "backdropFilter": "blur(12px) saturate(150%)",
304
304
  "backgroundColor": "panel-background",
305
305
  "bg": "panel-background",
306
+ "border": "1px solid",
307
+ "borderColor": "panel-divider",
308
+ "borderRadius": "card",
309
+ "boxShadow": "none",
310
+ "color": "text",
311
+ "flexGrow": 1,
312
+ "fontSize": [
313
+ 1,
314
+ 2,
315
+ ],
316
+ "padding": 3,
317
+ "textDecoration": "none",
318
+ },
319
+ "metricCardDark": {
320
+ "WebkitBackdropFilter": "blur(12px) saturate(150%)",
321
+ "backdropFilter": "blur(12px) saturate(150%)",
322
+ "backgroundColor": "#1e2530",
323
+ "bg": "panel-background",
324
+ "border": "1px solid",
325
+ "borderColor": "panel-divider",
306
326
  "borderRadius": "card",
307
327
  "boxShadow": "none",
308
328
  "color": "text",
@@ -312,11 +332,6 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
312
332
  2,
313
333
  ],
314
334
  "padding": 3,
315
- "span": {
316
- "fontFamily": "heading",
317
- "fontWeight": "bold",
318
- "padding": 2,
319
- },
320
335
  "textDecoration": "none",
321
336
  },
322
337
  "presentationalCard": {
@@ -805,6 +820,11 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
805
820
  "right",
806
821
  ],
807
822
  },
823
+ "mutedCardFooter": {
824
+ "display": "flex",
825
+ "justifyContent": "space-between",
826
+ "mt": 2,
827
+ },
808
828
  "outlined": {
809
829
  "border": "4px solid #efefef",
810
830
  },
@@ -0,0 +1,23 @@
1
+ import React from 'react'
2
+ import { Box } from '@theme-ui/components'
3
+
4
+ /**
5
+ * Styled label for post categories. Pass display text as `children` (site-specific mapping stays in the app).
6
+ */
7
+ const CategoryLabel = ({ children, sx: sxProp = {}, ...props }) => (
8
+ <Box
9
+ sx={{
10
+ display: 'inline-block',
11
+ fontSize: [0],
12
+ fontFamily: 'heading',
13
+ color: 'primary',
14
+ letterSpacing: '0.05em',
15
+ ...sxProp
16
+ }}
17
+ {...props}
18
+ >
19
+ {children}
20
+ </Box>
21
+ )
22
+
23
+ export default CategoryLabel
@@ -0,0 +1,24 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { ThemeUIProvider } from 'theme-ui'
3
+
4
+ import CategoryLabel from './category-label.js'
5
+
6
+ const theme = { colors: { primary: '#422EA3' } }
7
+
8
+ const renderWithTheme = ui => render(<ThemeUIProvider theme={theme}>{ui}</ThemeUIProvider>)
9
+
10
+ describe('CategoryLabel', () => {
11
+ it('renders children', () => {
12
+ renderWithTheme(<CategoryLabel>Travel</CategoryLabel>)
13
+ expect(screen.getByText('Travel')).toBeInTheDocument()
14
+ })
15
+
16
+ it('merges sx', () => {
17
+ renderWithTheme(
18
+ <CategoryLabel sx={{ color: 'red' }} data-testid='cat'>
19
+ X
20
+ </CategoryLabel>
21
+ )
22
+ expect(screen.getByTestId('cat')).toBeInTheDocument()
23
+ })
24
+ })
@@ -0,0 +1,37 @@
1
+ import React from 'react'
2
+
3
+ /** Chevron left (inline SVG, no icon font). */
4
+ export const ChevronLeftIcon = props => (
5
+ <svg
6
+ xmlns='http://www.w3.org/2000/svg'
7
+ viewBox='0 0 320 512'
8
+ width='1em'
9
+ height='1em'
10
+ aria-hidden='true'
11
+ focusable='false'
12
+ {...props}
13
+ >
14
+ <path
15
+ fill='currentColor'
16
+ d='M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z'
17
+ />
18
+ </svg>
19
+ )
20
+
21
+ /** Chevron right (inline SVG, no icon font). */
22
+ export const ChevronRightIcon = props => (
23
+ <svg
24
+ xmlns='http://www.w3.org/2000/svg'
25
+ viewBox='0 0 320 512'
26
+ width='1em'
27
+ height='1em'
28
+ aria-hidden='true'
29
+ focusable='false'
30
+ {...props}
31
+ >
32
+ <path
33
+ fill='currentColor'
34
+ d='M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z'
35
+ />
36
+ </svg>
37
+ )
@@ -0,0 +1,30 @@
1
+ import React from 'react'
2
+
3
+ /**
4
+ * Small external-link glyph (inline SVG). Use for “opens in new window” affordances.
5
+ */
6
+ export const ExternalLinkIcon = props => (
7
+ <svg
8
+ xmlns='http://www.w3.org/2000/svg'
9
+ viewBox='0 0 512 512'
10
+ width='0.75em'
11
+ height='0.75em'
12
+ aria-hidden='true'
13
+ focusable='false'
14
+ {...props}
15
+ >
16
+ <path
17
+ fill='currentColor'
18
+ d='M432 320h-32a16 16 0 0 0-16 16v112H64V128h144a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16H48a48 48 0 0 0-48 48v352a48 48 0 0 0 48 48h352a48 48 0 0 0 48-48V336a16 16 0 0 0-16-16zM488 0h-168c-13.3 0-24 10.7-24 24v8c0 13.3 10.7 24 24 24h69.2L207 279.6a24.06 24.06 0 0 0 0 34l10.2 10.2a24.06 24.06 0 0 0 34 0L425 128.8V200c0 13.3 10.7 24 24 24h8c13.3 0 24-10.7 24-24V56c0-30.9-25.1-56-56-56z'
19
+ />
20
+ </svg>
21
+ )
22
+
23
+ /** Same icon wrapped in `<span>` for drop-in use where a text sibling is expected. */
24
+ export default function ViewExternalLinkIcon() {
25
+ return (
26
+ <span>
27
+ <ExternalLinkIcon />
28
+ </span>
29
+ )
30
+ }
@@ -0,0 +1,16 @@
1
+ import { render, screen } from '@testing-library/react'
2
+
3
+ import ViewExternalLinkIcon, { ExternalLinkIcon } from './external-link-icon.js'
4
+
5
+ describe('ExternalLinkIcon', () => {
6
+ it('renders inline svg', () => {
7
+ const { container } = render(<ExternalLinkIcon data-testid='ico' />)
8
+ expect(container.querySelector('svg')).toBeInTheDocument()
9
+ expect(screen.getByTestId('ico')).toBeInTheDocument()
10
+ })
11
+
12
+ it('ViewExternalLinkIcon wraps icon in span', () => {
13
+ const { container } = render(<ViewExternalLinkIcon />)
14
+ expect(container.querySelector('span svg')).toBeInTheDocument()
15
+ })
16
+ })
@@ -0,0 +1,10 @@
1
+ import React from 'react'
2
+ import { Badge } from '@theme-ui/components'
3
+
4
+ const MetricBadge = ({ children, ...props }) => (
5
+ <Badge mr={3} {...props}>
6
+ {children}
7
+ </Badge>
8
+ )
9
+
10
+ export default MetricBadge
@@ -0,0 +1,15 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { ThemeUIProvider } from 'theme-ui'
3
+
4
+ import MetricBadge from './metric-badge.js'
5
+
6
+ describe('MetricBadge', () => {
7
+ it('renders children', () => {
8
+ render(
9
+ <ThemeUIProvider theme={{}}>
10
+ <MetricBadge>42</MetricBadge>
11
+ </ThemeUIProvider>
12
+ )
13
+ expect(screen.getByText('42')).toBeInTheDocument()
14
+ })
15
+ })
@@ -0,0 +1,95 @@
1
+ import React from 'react'
2
+ import { Box, Card, Text } from '@theme-ui/components'
3
+ import { useThemeUI } from 'theme-ui'
4
+
5
+ import isDarkMode from './helpers/isDarkMode.js'
6
+
7
+ /**
8
+ * Metric summary card (e.g. Goodreads profile metrics). Uses `metricCard` / `metricCardDark` card variants.
9
+ * When `loading` is true, renders `loadingSlot` or a lightweight pulse placeholder (no react-placeholder dependency).
10
+ */
11
+ const MetricCard = ({ title, value, loading = false, loadingSlot, sx, ...props }) => {
12
+ const { colorMode } = useThemeUI()
13
+ const variant = isDarkMode(colorMode) ? 'metricCardDark' : 'metricCard'
14
+
15
+ const body = loading ? (
16
+ (loadingSlot ?? (
17
+ <Box
18
+ aria-busy='true'
19
+ role='status'
20
+ sx={{
21
+ minHeight: '3rem',
22
+ borderRadius: 'default',
23
+ bg: 'muted',
24
+ opacity: 0.7,
25
+ animation: 'cgPulse 1.2s ease-in-out infinite',
26
+ '@keyframes cgPulse': {
27
+ '0%, 100%': { opacity: 0.45 },
28
+ '50%': { opacity: 0.85 }
29
+ }
30
+ }}
31
+ />
32
+ ))
33
+ ) : (
34
+ <Box
35
+ sx={{
36
+ display: 'flex',
37
+ flexDirection: 'column',
38
+ gap: 1,
39
+ alignItems: 'center',
40
+ justifyContent: 'center',
41
+ textAlign: 'center',
42
+ minWidth: 0,
43
+ width: '100%'
44
+ }}
45
+ >
46
+ <Text
47
+ as='span'
48
+ sx={{
49
+ fontFamily: 'heading',
50
+ fontWeight: 'bold',
51
+ fontSize: [4, 5],
52
+ lineHeight: 1.1,
53
+ color: 'text',
54
+ m: 0,
55
+ letterSpacing: '-0.02em'
56
+ }}
57
+ >
58
+ {value}
59
+ </Text>
60
+ <Text
61
+ as='span'
62
+ sx={{
63
+ fontSize: 0,
64
+ color: 'textMuted',
65
+ lineHeight: 1.35,
66
+ m: 0,
67
+ textTransform: 'uppercase',
68
+ letterSpacing: '0.06em',
69
+ fontWeight: 'medium'
70
+ }}
71
+ >
72
+ {title}
73
+ </Text>
74
+ </Box>
75
+ )
76
+
77
+ return (
78
+ <Card
79
+ variant={variant}
80
+ sx={{
81
+ display: 'flex',
82
+ alignItems: 'stretch',
83
+ justifyContent: 'center',
84
+ minHeight: '6rem',
85
+ py: 3,
86
+ ...sx
87
+ }}
88
+ {...props}
89
+ >
90
+ {body}
91
+ </Card>
92
+ )
93
+ }
94
+
95
+ export default MetricCard
@@ -0,0 +1,60 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { ThemeUIProvider } from 'theme-ui'
3
+
4
+ import MetricCard from './metric-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 = { colors: { modes: { dark: {} } } }
17
+
18
+ describe('MetricCard', () => {
19
+ beforeEach(() => {
20
+ useThemeUI.mockReturnValue({ colorMode: 'default' })
21
+ })
22
+
23
+ it('renders value and title when not loading', () => {
24
+ render(
25
+ <ThemeUIProvider theme={theme}>
26
+ <MetricCard title='Followers' value='12' />
27
+ </ThemeUIProvider>
28
+ )
29
+ expect(screen.getByText('12')).toBeInTheDocument()
30
+ expect(screen.getByText('Followers')).toBeInTheDocument()
31
+ })
32
+
33
+ it('shows default loading placeholder when loading', () => {
34
+ render(
35
+ <ThemeUIProvider theme={theme}>
36
+ <MetricCard title='F' value='1' loading />
37
+ </ThemeUIProvider>
38
+ )
39
+ expect(screen.getByRole('status')).toBeInTheDocument()
40
+ })
41
+
42
+ it('uses metricCardDark when dark', () => {
43
+ useThemeUI.mockReturnValue({ colorMode: 'dark' })
44
+ render(
45
+ <ThemeUIProvider theme={theme}>
46
+ <MetricCard title='T' value='v' loading={false} />
47
+ </ThemeUIProvider>
48
+ )
49
+ expect(screen.getByText('v')).toBeInTheDocument()
50
+ })
51
+
52
+ it('uses custom loadingSlot', () => {
53
+ render(
54
+ <ThemeUIProvider theme={theme}>
55
+ <MetricCard title='t' value='v' loading loadingSlot={<span data-testid='slot'>wait</span>} />
56
+ </ThemeUIProvider>
57
+ )
58
+ expect(screen.getByTestId('slot')).toBeInTheDocument()
59
+ })
60
+ })
@@ -0,0 +1,22 @@
1
+ import React from 'react'
2
+ import { Box } from '@theme-ui/components'
3
+
4
+ /**
5
+ * Muted footer row for dashboard-style cards. Uses `styles.mutedCardFooter` on the theme.
6
+ */
7
+ const MutedCardFooter = ({ children, customStyles, ...props }) => (
8
+ <Box
9
+ sx={{
10
+ variant: 'styles.mutedCardFooter',
11
+ color: 'textMuted',
12
+ fontFamily: 'sans',
13
+ fontSize: 1,
14
+ ...(typeof customStyles === 'object' && customStyles !== null ? customStyles : {})
15
+ }}
16
+ {...props}
17
+ >
18
+ {children}
19
+ </Box>
20
+ )
21
+
22
+ export default MutedCardFooter
@@ -0,0 +1,25 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { ThemeUIProvider } from 'theme-ui'
3
+
4
+ import MutedCardFooter from './muted-card-footer.js'
5
+
6
+ const theme = { styles: { mutedCardFooter: { mt: 2 } } }
7
+
8
+ const wrap = ui => render(<ThemeUIProvider theme={theme}>{ui}</ThemeUIProvider>)
9
+
10
+ describe('MutedCardFooter', () => {
11
+ it('renders children', () => {
12
+ wrap(<MutedCardFooter>Footer text</MutedCardFooter>)
13
+ expect(screen.getByText('Footer text')).toBeInTheDocument()
14
+ })
15
+
16
+ it('merges customStyles when an object', () => {
17
+ wrap(<MutedCardFooter customStyles={{ mt: 4 }}>A</MutedCardFooter>)
18
+ expect(screen.getByText('A')).toBeInTheDocument()
19
+ })
20
+
21
+ it('ignores non-object customStyles', () => {
22
+ wrap(<MutedCardFooter customStyles='invalid'>B</MutedCardFooter>)
23
+ expect(screen.getByText('B')).toBeInTheDocument()
24
+ })
25
+ })
@@ -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