@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
package/README.md
CHANGED
|
@@ -24,9 +24,8 @@ Prefer deep imports so bundles stay lean:
|
|
|
24
24
|
| `@chronogrove/ui/theme` | Default Theme UI theme object + named exports |
|
|
25
25
|
| `@chronogrove/ui/provider` | `ChronogroveThemeProvider` |
|
|
26
26
|
| `@chronogrove/ui/color-mode` | Storage key, reconcile event, SSR inline builders, `chronogroveHeadTheme` (RSC-safe), `resolveChronogroveSurfaceColors`, `useDocumentColorModeSurface`, browser sync, `reconcileThemeUiColorModeOnNavigation` |
|
|
27
|
-
| `@chronogrove/ui/animated-page-background` | **`ChronogroveAnimatedPageBackground`** — same stack as the Gatsby home: fixed `z-index: 0`, light = solid theme background, dark = **three.js** Color Bends + scroll-linked gradient overlay and parallax (`three` is a dependency of this package).
|
|
27
|
+
| `@chronogrove/ui/animated-page-background` | **`ChronogroveAnimatedPageBackground`** — same stack as the Gatsby home: fixed `z-index: 0`, light = solid theme background, dark = **three.js** Color Bends + scroll-linked gradient overlay and parallax (`three` is a dependency of this package). |
|
|
28
28
|
| `@chronogrove/ui/color-bends` | **`ColorBends`** — lower-level three.js gradient background used inside `ChronogroveAnimatedPageBackground`. Prefer the full **`animated-page-background`** or **`@chronogrove/ui/next`** shell unless you need the raw component. |
|
|
29
|
-
| `@chronogrove/ui/page-backdrop` | **`ChronogrovePageBackdrop`** — lightweight alternative: fixed `z-index: 0` fill without WebGL (CSS gradients in dark mode). Use when you cannot ship `three`. |
|
|
30
29
|
| `@chronogrove/ui/next` | **Next.js App Router helpers:** `ChronogroveNextRootLayoutHead` (RSC `<head>` injections), `ChronogroveNextEmotionRegistry`, `ChronogroveNextAppShell` (theme + three.js background + surface sync + soft-nav reconcile), `ChronogroveNextThemeUiColorModeRouteSync` (standalone). Requires `next` (peer, optional for the rest of the package). |
|
|
31
30
|
| `@chronogrove/ui/action-card-layout` | **`actionCardPinnedLayoutSx`** — layout `sx` for `Card variant="actionCard"` (matches GitHub pinned cards). |
|
|
32
31
|
| `@chronogrove/ui/emotion-cache` | `createChronogroveEmotionCache`, `getChronogroveEmotionCache` |
|
|
@@ -41,8 +40,12 @@ Prefer deep imports so bundles stay lean:
|
|
|
41
40
|
| `@chronogrove/ui/lazy-load` | Defer children until in viewport (`react-intersection-observer`) |
|
|
42
41
|
| `@chronogrove/ui/header` | Masthead shell (`variant: styles.Header`) |
|
|
43
42
|
| `@chronogrove/ui/page-header` | Blog-style `h1` heading (`p-name`) |
|
|
43
|
+
| `@chronogrove/ui/profile-metrics-badge` | Metrics row (`Badge variant="metrics"`) for dashboard widget headers |
|
|
44
|
+
| `@chronogrove/ui/widget-header` | Widget section title row (optional Font Awesome icon, aside slot, optional metrics) |
|
|
44
45
|
| `@chronogrove/ui/gatsby` | Color-mode Gatsby SSR/browser helpers |
|
|
45
46
|
|
|
47
|
+
**Additional subpaths:** The table lists the most common entry points. Also published: **`pagination`** (full bar; composes **`pagination-button`**), **`category-label`**, **`metric-badge`**, **`metric-card`**, **`muted-card-footer`**, **`status-card`**, **`widget-section`**, **`widget-call-to-action`**, **`external-link-icon`**. The authoritative list is **`package.json`** → **`exports`**.
|
|
48
|
+
|
|
46
49
|
## Next.js (App Router)
|
|
47
50
|
|
|
48
51
|
**Reference app:** [`examples/chronogrove-next`](../../examples/chronogrove-next) (`chronogrove-next`). Run `pnpm --filter chronogrove-next dev` from the repo root.
|
|
@@ -54,7 +57,9 @@ Prefer deep imports so bundles stay lean:
|
|
|
54
57
|
- **Server `layout`:** Keep the root layout a Server Component. **Do not** import `@chronogrove/ui/theme` here—it loads `theme-ui`’s `merge` and triggers React `createContext`, which Next.js disallows in RSC. Use **`ChronogroveNextRootLayoutHead`** from `@chronogrove/ui/next`, or manually pass **`chronogroveHeadTheme`** from `@chronogrove/ui/color-mode` to `resolveChronogroveSurfaceColors` and emit **in order**: `<meta name="emotion-insertion-point" content="" />`, then the Theme UI no-flash script, HTML background script, and fallback CSS (same composition as [`buildThemeUiColorModeHeadComponents`](./src/gatsby/build-theme-ui-color-mode-head-components.js) in `@chronogrove/ui/gatsby`).
|
|
55
58
|
- **Client Emotion registry + theme:** Use **`ChronogroveNextEmotionRegistry`** from `@chronogrove/ui/next`, or wrap the app in Emotion’s `CacheProvider` with a **per-request** cache with `key: 'css'` and Next’s `useServerInsertedHTML` ([Next.js: CSS-in-JS](https://nextjs.org/docs/app/building-your-application/styling/css-in-js)). **Do not** rely on `getChronogroveEmotionCache()` for SSR—it is a browser-oriented singleton. Import the **full** theme from `@chronogrove/ui/theme` only inside client components. Anything using `useColorMode`, `useThemeUI`, or `ChronogroveThemeProvider` must be `'use client'`. **Order:** `CacheProvider` (registry) **outside** `ChronogroveThemeProvider` **inside** `<body>`.
|
|
56
59
|
- **Document surface (match Gatsby `RootWrapper`):** **`ChronogroveNextAppShell`** calls **`useDocumentColorModeSurface`** from `@chronogrove/ui/color-mode` once. It syncs `document.documentElement`’s `theme-ui-*` class, `data-theme-ui-color-mode`, and inline page background from the **resolved** Theme UI theme (`rawColors` / `colors.background`). Without it, Emotion can win the cascade over head fallback CSS and panel tokens (`bg: 'panel-background'`) may not update in dark mode after hydration.
|
|
57
|
-
- **Animated page background (match Gatsby home):** **`ChronogroveNextAppShell`** includes **`ChronogroveAnimatedPageBackground`** (three.js Color Bends).
|
|
60
|
+
- **Animated page background (match Gatsby home):** **`ChronogroveNextAppShell`** includes **`ChronogroveAnimatedPageBackground`** (three.js Color Bends). Content should sit in a **`position: relative; z-index: 1`** wrapper above the fixed background layer.
|
|
61
|
+
- **`WidgetHeader` + icons:** This package depends on **`@fortawesome/fontawesome-svg-core`** and **`@fortawesome/react-fontawesome`**. Icon **definitions** (e.g. `faGithub`) come from a Font Awesome kit you add to **your** app—typically **`@fortawesome/free-brands-svg-icons`** for brands—the same pattern as **`gatsby-theme-chronogrove`** widgets.
|
|
62
|
+
- **`MetricCard` loading state:** Pass **`loading`** or **`showPlaceholder`** (equivalent) for the built-in pulse placeholder (or **`loadingSlot`** to override).
|
|
58
63
|
- **Soft navigations:** **`ChronogroveNextAppShell`** includes **`ChronogroveNextThemeUiColorModeRouteSync`**, which calls `reconcileThemeUiColorModeOnNavigation` after pathname changes (not on initial mount). Running reconcile on mount can fight `useColorMode` toggles. Gatsby’s equivalent is `onRouteUpdateThemeUiColorMode` (no initial `onRouteUpdate`).
|
|
59
64
|
- **Hydration:** Set `suppressHydrationWarning` on `<html>` and `<body>`. The inline no-flash / background scripts run before React hydrates and update `<html>` (`theme-ui-*` classes, `data-theme-ui-color-mode`, background), so the DOM no longer matches the server-rendered markup—React would warn without this flag (similar to [next-themes](https://github.com/pacocoursey/next-themes) on Next.js App Router).
|
|
60
65
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronogrove/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.82.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,46 @@
|
|
|
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
|
+
},
|
|
105
|
+
"./widget-header": {
|
|
106
|
+
"import": "./src/widget-header.js",
|
|
107
|
+
"default": "./src/widget-header.js"
|
|
108
|
+
},
|
|
69
109
|
"./lazy-load": {
|
|
70
110
|
"import": "./src/lazy-load.js",
|
|
71
111
|
"default": "./src/lazy-load.js"
|
|
@@ -82,9 +122,9 @@
|
|
|
82
122
|
"import": "./src/page-header.js",
|
|
83
123
|
"default": "./src/page-header.js"
|
|
84
124
|
},
|
|
85
|
-
"./
|
|
86
|
-
"import": "./src/
|
|
87
|
-
"default": "./src/
|
|
125
|
+
"./profile-metrics-badge": {
|
|
126
|
+
"import": "./src/profile-metrics-badge.js",
|
|
127
|
+
"default": "./src/profile-metrics-badge.js"
|
|
88
128
|
},
|
|
89
129
|
"./animated-page-background": {
|
|
90
130
|
"import": "./src/animated-page-background/index.js",
|
|
@@ -116,6 +156,8 @@
|
|
|
116
156
|
"dependencies": {
|
|
117
157
|
"@emotion/cache": "^11.14.0",
|
|
118
158
|
"@emotion/react": "^11.14.0",
|
|
159
|
+
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
|
160
|
+
"@fortawesome/react-fontawesome": "^3.3.0",
|
|
119
161
|
"@theme-toggles/react": "^4.1.0",
|
|
120
162
|
"@theme-ui/components": "^0.17.4",
|
|
121
163
|
"@theme-ui/presets": "^0.17.4",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
|
2
|
+
|
|
3
|
+
exports[`ProfileMetricsBadge matches the snapshot with metrics 1`] = `
|
|
4
|
+
<DocumentFragment>
|
|
5
|
+
<div
|
|
6
|
+
class="css-1182k7c"
|
|
7
|
+
>
|
|
8
|
+
<div
|
|
9
|
+
class="css-1njnxko"
|
|
10
|
+
>
|
|
11
|
+
10 Followers
|
|
12
|
+
</div>
|
|
13
|
+
<div
|
|
14
|
+
class="css-137c6l"
|
|
15
|
+
>
|
|
16
|
+
20 Following
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
</DocumentFragment>
|
|
20
|
+
`;
|
|
@@ -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,33 @@
|
|
|
1
|
+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
|
2
|
+
|
|
3
|
+
exports[`WidgetHeader matches the snapshot 1`] = `
|
|
4
|
+
<DocumentFragment>
|
|
5
|
+
<header
|
|
6
|
+
class="css-7pzsne"
|
|
7
|
+
>
|
|
8
|
+
<div
|
|
9
|
+
class="css-s076pz"
|
|
10
|
+
>
|
|
11
|
+
<h2
|
|
12
|
+
class="css-16f4ihe"
|
|
13
|
+
>
|
|
14
|
+
<span
|
|
15
|
+
class="css-2s41mq"
|
|
16
|
+
>
|
|
17
|
+
<span
|
|
18
|
+
aria-hidden="true"
|
|
19
|
+
data-icon="spotify"
|
|
20
|
+
data-testid="fa-icon"
|
|
21
|
+
/>
|
|
22
|
+
</span>
|
|
23
|
+
Neat & Interesting Widget
|
|
24
|
+
</h2>
|
|
25
|
+
<div
|
|
26
|
+
class="sidebar-content"
|
|
27
|
+
>
|
|
28
|
+
Sidebar
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</header>
|
|
32
|
+
</DocumentFragment>
|
|
33
|
+
`;
|
|
@@ -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,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,96 @@
|
|
|
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` or `showPlaceholder` is true, renders `loadingSlot` or a lightweight pulse placeholder (no react-placeholder dependency).
|
|
10
|
+
*/
|
|
11
|
+
const MetricCard = ({ title, value, loading = false, showPlaceholder, loadingSlot, sx, ...props }) => {
|
|
12
|
+
const { colorMode } = useThemeUI()
|
|
13
|
+
const variant = isDarkMode(colorMode) ? 'metricCardDark' : 'metricCard'
|
|
14
|
+
const isLoading = Boolean(loading || showPlaceholder)
|
|
15
|
+
|
|
16
|
+
const body = isLoading ? (
|
|
17
|
+
(loadingSlot ?? (
|
|
18
|
+
<Box
|
|
19
|
+
aria-busy='true'
|
|
20
|
+
role='status'
|
|
21
|
+
sx={{
|
|
22
|
+
minHeight: '3rem',
|
|
23
|
+
borderRadius: 'default',
|
|
24
|
+
bg: 'muted',
|
|
25
|
+
opacity: 0.7,
|
|
26
|
+
animation: 'cgPulse 1.2s ease-in-out infinite',
|
|
27
|
+
'@keyframes cgPulse': {
|
|
28
|
+
'0%, 100%': { opacity: 0.45 },
|
|
29
|
+
'50%': { opacity: 0.85 }
|
|
30
|
+
}
|
|
31
|
+
}}
|
|
32
|
+
/>
|
|
33
|
+
))
|
|
34
|
+
) : (
|
|
35
|
+
<Box
|
|
36
|
+
sx={{
|
|
37
|
+
display: 'flex',
|
|
38
|
+
flexDirection: 'column',
|
|
39
|
+
gap: 1,
|
|
40
|
+
alignItems: 'center',
|
|
41
|
+
justifyContent: 'center',
|
|
42
|
+
textAlign: 'center',
|
|
43
|
+
minWidth: 0,
|
|
44
|
+
width: '100%'
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
<Text
|
|
48
|
+
as='span'
|
|
49
|
+
sx={{
|
|
50
|
+
fontFamily: 'heading',
|
|
51
|
+
fontWeight: 'bold',
|
|
52
|
+
fontSize: [4, 5],
|
|
53
|
+
lineHeight: 1.1,
|
|
54
|
+
color: 'text',
|
|
55
|
+
m: 0,
|
|
56
|
+
letterSpacing: '-0.02em'
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
{value}
|
|
60
|
+
</Text>
|
|
61
|
+
<Text
|
|
62
|
+
as='span'
|
|
63
|
+
sx={{
|
|
64
|
+
fontSize: 0,
|
|
65
|
+
color: 'textMuted',
|
|
66
|
+
lineHeight: 1.35,
|
|
67
|
+
m: 0,
|
|
68
|
+
textTransform: 'uppercase',
|
|
69
|
+
letterSpacing: '0.06em',
|
|
70
|
+
fontWeight: 'medium'
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{title}
|
|
74
|
+
</Text>
|
|
75
|
+
</Box>
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Card
|
|
80
|
+
variant={variant}
|
|
81
|
+
sx={{
|
|
82
|
+
display: 'flex',
|
|
83
|
+
alignItems: 'stretch',
|
|
84
|
+
justifyContent: 'center',
|
|
85
|
+
minHeight: '6rem',
|
|
86
|
+
py: 3,
|
|
87
|
+
...sx
|
|
88
|
+
}}
|
|
89
|
+
{...props}
|
|
90
|
+
>
|
|
91
|
+
{body}
|
|
92
|
+
</Card>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default MetricCard
|
|
@@ -0,0 +1,69 @@
|
|
|
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('treats showPlaceholder as loading', () => {
|
|
43
|
+
render(
|
|
44
|
+
<ThemeUIProvider theme={theme}>
|
|
45
|
+
<MetricCard title='F' value='1' showPlaceholder />
|
|
46
|
+
</ThemeUIProvider>
|
|
47
|
+
)
|
|
48
|
+
expect(screen.getByRole('status')).toBeInTheDocument()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('uses metricCardDark when dark', () => {
|
|
52
|
+
useThemeUI.mockReturnValue({ colorMode: 'dark' })
|
|
53
|
+
render(
|
|
54
|
+
<ThemeUIProvider theme={theme}>
|
|
55
|
+
<MetricCard title='T' value='v' loading={false} />
|
|
56
|
+
</ThemeUIProvider>
|
|
57
|
+
)
|
|
58
|
+
expect(screen.getByText('v')).toBeInTheDocument()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('uses custom loadingSlot', () => {
|
|
62
|
+
render(
|
|
63
|
+
<ThemeUIProvider theme={theme}>
|
|
64
|
+
<MetricCard title='t' value='v' loading loadingSlot={<span data-testid='slot'>wait</span>} />
|
|
65
|
+
</ThemeUIProvider>
|
|
66
|
+
)
|
|
67
|
+
expect(screen.getByTestId('slot')).toBeInTheDocument()
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -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
|
+
})
|