@chronogrove/ui 0.82.3 → 0.83.1
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 +74 -1
- package/package.json +21 -1
- package/src/__snapshots__/image-thumbnails.spec.js.snap +61 -0
- package/src/__snapshots__/thumbnail-strip.spec.js.snap +61 -0
- package/src/article-column-container.js +23 -0
- package/src/article-column-container.spec.js +40 -0
- package/src/home-dashboard-layout.js +58 -0
- package/src/home-dashboard-layout.spec.js +60 -0
- package/src/image-thumbnails.js +89 -0
- package/src/image-thumbnails.spec.js +95 -0
- package/src/page-shell-layout.js +57 -0
- package/src/page-shell-layout.spec.js +71 -0
- package/src/thumbnail-strip.js +72 -0
- package/src/thumbnail-strip.spec.js +83 -0
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ Prefer deep imports so bundles stay lean:
|
|
|
44
44
|
| `@chronogrove/ui/widget-header` | Widget section title row (optional Font Awesome icon, aside slot, optional metrics) |
|
|
45
45
|
| `@chronogrove/ui/gatsby` | Color-mode Gatsby SSR/browser helpers |
|
|
46
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
|
|
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`**, **`thumbnail-strip`**, **`image-thumbnails`** (`optimizeSrc` for CDN resizing; Gatsby passes a Cloudinary helper). The authoritative list is **`package.json`** → **`exports`**.
|
|
48
48
|
|
|
49
49
|
## Next.js (App Router)
|
|
50
50
|
|
|
@@ -67,6 +67,79 @@ Prefer deep imports so bundles stay lean:
|
|
|
67
67
|
|
|
68
68
|
**JSX + bundlers:** Primitives such as [`button`](./src/button.js) use [`Box`](https://theme-ui.com/components/box) from `@theme-ui/components` with an `as` prop for native elements, so `sx` works with Gatsby’s classic JSX runtime and Next’s SWC without a Theme UI file pragma. Jest uses [`babel.config.cjs`](./babel.config.cjs) (automatic JSX).
|
|
69
69
|
|
|
70
|
+
## Global CSS, Prism / third-party CSS, and fonts
|
|
71
|
+
|
|
72
|
+
Styles load in three layers. Understanding the split prevents accidental duplicates and keeps each host's build minimal.
|
|
73
|
+
|
|
74
|
+
### Layer order
|
|
75
|
+
|
|
76
|
+
| # | Layer | Mechanism |
|
|
77
|
+
| --- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
78
|
+
| 1 | **Head — color-mode + Emotion insertion point** | Gatsby: `theme/gatsby-ssr.js` + `@chronogrove/ui/gatsby` helpers. Next: `ChronogroveNextRootLayoutHead` from `@chronogrove/ui/next`. Emit before any `<style>` tags so Emotion and Theme UI no-flash scripts run first. |
|
|
79
|
+
| 2 | **Theme UI globals (`theme.global`)** | `ChronogroveThemeProvider` renders `<Global styles={theme.global} />` via Emotion. This applies identical baseline CSS under both Gatsby and Next as long as the provider wraps the app. No extra import needed. |
|
|
80
|
+
| 3 | **Host global CSS** | Each host owns its plain-CSS files. See below for Gatsby and Next specifics. |
|
|
81
|
+
|
|
82
|
+
### Gatsby
|
|
83
|
+
|
|
84
|
+
The theme's own global CSS entry is the side-effect import block in [`theme/gatsby-browser.js`](../../theme/gatsby-browser.js):
|
|
85
|
+
|
|
86
|
+
```javascript
|
|
87
|
+
import './src/styles/global.css' // layout, .sr-only, Prism overrides
|
|
88
|
+
import 'lightgallery/css/lightgallery.css'
|
|
89
|
+
import 'lightgallery/css/lg-thumbnail.css'
|
|
90
|
+
import 'lightgallery/css/lg-zoom.css'
|
|
91
|
+
import 'prismjs/themes/prism-solarizedlight.css'
|
|
92
|
+
import 'prismjs/plugins/line-numbers/prism-line-numbers.css'
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
[`theme/src/styles/global.css`](../../theme/src/styles/global.css) also contains `@import '@chronogrove/ui/color-toggle-styles'` for the theme toggle animation.
|
|
96
|
+
|
|
97
|
+
Prism is **tightly coupled to `gatsby-remark-prismjs`** (configured in [`theme/gatsby-config.js`](../../theme/gatsby-config.js)). The CSS theme and helper overrides (`.gatsby-highlight`, line-number padding, etc.) only make sense when that plugin is active. They are intentionally kept in the Gatsby theme rather than in this package.
|
|
98
|
+
|
|
99
|
+
Sites that shadow or extend the theme can add additional side-effect imports in their own `gatsby-browser.js` alongside (not instead of) the theme's entry.
|
|
100
|
+
|
|
101
|
+
### Next.js (App Router)
|
|
102
|
+
|
|
103
|
+
The reference app is [`examples/chronogrove-next`](../../examples/chronogrove-next). Its [`app/globals.css`](../../examples/chronogrove-next/app/globals.css) shows the minimal baseline:
|
|
104
|
+
|
|
105
|
+
```css
|
|
106
|
+
/* Required if you use ColorToggle */
|
|
107
|
+
@import '@chronogrove/ui/color-toggle-styles';
|
|
108
|
+
|
|
109
|
+
html {
|
|
110
|
+
scroll-behavior: smooth;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
body {
|
|
114
|
+
background-color: var(--theme-ui-colors-background);
|
|
115
|
+
-webkit-font-smoothing: antialiased;
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Import `globals.css` once from the root `layout.jsx`/`layout.tsx` — the same file that renders `ChronogroveNextRootLayoutHead` and `ChronogroveNextEmotionRegistry`. See [`examples/chronogrove-next/app/layout.jsx`](../../examples/chronogrove-next/app/layout.jsx).
|
|
120
|
+
|
|
121
|
+
For syntax highlighting in a Next MDX pipeline, use whatever highlighter fits your setup (e.g. [Shiki via rehype-pretty-code](https://rehype-pretty.pages.dev/)) and load only its CSS. There is no dependency on `prismjs` in this package, and you should not add the Prism CSS from the Gatsby theme to a Next app unless your MDX pipeline explicitly emits Prism class names.
|
|
122
|
+
|
|
123
|
+
### Fonts
|
|
124
|
+
|
|
125
|
+
The default Chronogrove theme uses **system font stacks** only (defined in [`src/theme.js`](./src/theme.js)):
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
sans: '-apple-system, BlinkMacSystemFont, avenir next, ..., sans-serif'
|
|
129
|
+
mono: 'Menlo, Consolas, ..., monospace'
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
No `@font-face` rules or external font URLs are shipped by this package. Web fonts are **host-owned**:
|
|
133
|
+
|
|
134
|
+
- **Next.js:** use [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to load faces with zero layout shift; then pass the resulting CSS variable or family name into a theme override: `{ fonts: { body: 'var(--font-inter), sans-serif' } }`.
|
|
135
|
+
- **Gatsby:** use [`gatsby-plugin-google-gtag`](https://www.gatsbyjs.com/plugins/gatsby-plugin-google-gtag/) or a `<link>` in `gatsby-ssr.js`, then extend `theme.fonts` in your `gatsby-config.js` theme options.
|
|
136
|
+
|
|
137
|
+
Changing `theme.fonts` is the only hook needed; `ChronogroveThemeProvider` merges the override automatically via Theme UI.
|
|
138
|
+
|
|
139
|
+
### Font Awesome (icons)
|
|
140
|
+
|
|
141
|
+
`@chronogrove/ui` depends on `@fortawesome/fontawesome-svg-core` and `@fortawesome/react-fontawesome` for `WidgetHeader`. Icon **definitions** (e.g. `faGithub`) come from a kit you add to your own app — see the `WidgetHeader + icons` bullet in the [Next.js](#nextjs-app-router) section above.
|
|
142
|
+
|
|
70
143
|
## Changelog
|
|
71
144
|
|
|
72
145
|
Releases are recorded in the repository root [`CHANGELOG.md`](../../CHANGELOG.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronogrove/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.83.1",
|
|
4
4
|
"description": "Chronogrove Theme UI theme, color mode helpers, and shared UI primitives",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -138,6 +138,26 @@
|
|
|
138
138
|
"import": "./src/action-card-layout.js",
|
|
139
139
|
"default": "./src/action-card-layout.js"
|
|
140
140
|
},
|
|
141
|
+
"./article-column-container": {
|
|
142
|
+
"import": "./src/article-column-container.js",
|
|
143
|
+
"default": "./src/article-column-container.js"
|
|
144
|
+
},
|
|
145
|
+
"./home-dashboard-layout": {
|
|
146
|
+
"import": "./src/home-dashboard-layout.js",
|
|
147
|
+
"default": "./src/home-dashboard-layout.js"
|
|
148
|
+
},
|
|
149
|
+
"./page-shell-layout": {
|
|
150
|
+
"import": "./src/page-shell-layout.js",
|
|
151
|
+
"default": "./src/page-shell-layout.js"
|
|
152
|
+
},
|
|
153
|
+
"./thumbnail-strip": {
|
|
154
|
+
"import": "./src/thumbnail-strip.js",
|
|
155
|
+
"default": "./src/thumbnail-strip.js"
|
|
156
|
+
},
|
|
157
|
+
"./image-thumbnails": {
|
|
158
|
+
"import": "./src/image-thumbnails.js",
|
|
159
|
+
"default": "./src/image-thumbnails.js"
|
|
160
|
+
},
|
|
141
161
|
"./gatsby": {
|
|
142
162
|
"import": "./src/gatsby/index.js",
|
|
143
163
|
"default": "./src/gatsby/index.js"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
|
2
|
+
|
|
3
|
+
exports[`ImageThumbnails matches snapshot with custom maxImages 1`] = `
|
|
4
|
+
<DocumentFragment>
|
|
5
|
+
<div
|
|
6
|
+
class="css-1ud2rd2"
|
|
7
|
+
>
|
|
8
|
+
<div
|
|
9
|
+
class="css-1jg6seg"
|
|
10
|
+
>
|
|
11
|
+
<div
|
|
12
|
+
class="css-m0n9e0"
|
|
13
|
+
/>
|
|
14
|
+
</div>
|
|
15
|
+
<div
|
|
16
|
+
class="css-1da34oq"
|
|
17
|
+
>
|
|
18
|
+
<div
|
|
19
|
+
class="css-1yk8ryr"
|
|
20
|
+
/>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
</DocumentFragment>
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
exports[`ImageThumbnails matches snapshot with default props 1`] = `
|
|
27
|
+
<DocumentFragment>
|
|
28
|
+
<div
|
|
29
|
+
class="css-1ud2rd2"
|
|
30
|
+
>
|
|
31
|
+
<div
|
|
32
|
+
class="css-1jg6seg"
|
|
33
|
+
>
|
|
34
|
+
<div
|
|
35
|
+
class="css-m0n9e0"
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
38
|
+
<div
|
|
39
|
+
class="css-1da34oq"
|
|
40
|
+
>
|
|
41
|
+
<div
|
|
42
|
+
class="css-1yk8ryr"
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
<div
|
|
46
|
+
class="css-1jg6seg"
|
|
47
|
+
>
|
|
48
|
+
<div
|
|
49
|
+
class="css-1udw8eu"
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
<div
|
|
53
|
+
class="css-1da34oq"
|
|
54
|
+
>
|
|
55
|
+
<div
|
|
56
|
+
class="css-dvh21o"
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</DocumentFragment>
|
|
61
|
+
`;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
|
2
|
+
|
|
3
|
+
exports[`ThumbnailStrip matches snapshot with custom maxImages and size 1`] = `
|
|
4
|
+
<DocumentFragment>
|
|
5
|
+
<div
|
|
6
|
+
class="css-dpp4jp"
|
|
7
|
+
>
|
|
8
|
+
<div
|
|
9
|
+
class="css-1p4ud4e"
|
|
10
|
+
>
|
|
11
|
+
<div
|
|
12
|
+
class="css-m0n9e0"
|
|
13
|
+
/>
|
|
14
|
+
</div>
|
|
15
|
+
<div
|
|
16
|
+
class="css-zlkqv4"
|
|
17
|
+
>
|
|
18
|
+
<div
|
|
19
|
+
class="css-1yk8ryr"
|
|
20
|
+
/>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
</DocumentFragment>
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
exports[`ThumbnailStrip matches snapshot with default props 1`] = `
|
|
27
|
+
<DocumentFragment>
|
|
28
|
+
<div
|
|
29
|
+
class="css-1httq4l"
|
|
30
|
+
>
|
|
31
|
+
<div
|
|
32
|
+
class="css-1jl8pr3"
|
|
33
|
+
>
|
|
34
|
+
<div
|
|
35
|
+
class="css-m0n9e0"
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
38
|
+
<div
|
|
39
|
+
class="css-f02gwb"
|
|
40
|
+
>
|
|
41
|
+
<div
|
|
42
|
+
class="css-1yk8ryr"
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
<div
|
|
46
|
+
class="css-44fsif"
|
|
47
|
+
>
|
|
48
|
+
<div
|
|
49
|
+
class="css-1udw8eu"
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
<div
|
|
53
|
+
class="css-ladzy1"
|
|
54
|
+
>
|
|
55
|
+
<div
|
|
56
|
+
class="css-dvh21o"
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</DocumentFragment>
|
|
61
|
+
`;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Container } from '@theme-ui/components'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Primary text column width for MDX posts, blog index, and about-style pages.
|
|
6
|
+
* Keep in sync wherever the article measure must match (Gatsby post template, blog index, etc.).
|
|
7
|
+
*/
|
|
8
|
+
export const articleColumnContainerSx = {
|
|
9
|
+
position: 'relative',
|
|
10
|
+
width: ['', '', 'max(80ch, 50vw)'],
|
|
11
|
+
lineHeight: 1.7
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Theme UI `Container` with {@link articleColumnContainerSx}. Pass `sx` to merge (e.g. `flexGrow: 1` on the blog index).
|
|
16
|
+
*/
|
|
17
|
+
export function ArticleColumnContainer({ children, sx = {}, ...rest }) {
|
|
18
|
+
return (
|
|
19
|
+
<Container sx={{ ...articleColumnContainerSx, ...sx }} {...rest}>
|
|
20
|
+
{children}
|
|
21
|
+
</Container>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { ChronogroveThemeProvider } from './provider.js'
|
|
4
|
+
import chronogroveTheme from './theme.js'
|
|
5
|
+
|
|
6
|
+
import { ArticleColumnContainer, articleColumnContainerSx } from './article-column-container.js'
|
|
7
|
+
|
|
8
|
+
describe('articleColumnContainerSx', () => {
|
|
9
|
+
it('defines the shared article measure', () => {
|
|
10
|
+
expect(articleColumnContainerSx).toMatchObject({
|
|
11
|
+
position: 'relative',
|
|
12
|
+
width: ['', '', 'max(80ch, 50vw)'],
|
|
13
|
+
lineHeight: 1.7
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('ArticleColumnContainer', () => {
|
|
19
|
+
it('renders children with merged sx', () => {
|
|
20
|
+
render(
|
|
21
|
+
<ChronogroveThemeProvider theme={chronogroveTheme}>
|
|
22
|
+
<ArticleColumnContainer sx={{ flexGrow: 1 }} data-testid='ac'>
|
|
23
|
+
<span>body</span>
|
|
24
|
+
</ArticleColumnContainer>
|
|
25
|
+
</ChronogroveThemeProvider>
|
|
26
|
+
)
|
|
27
|
+
expect(screen.getByTestId('ac')).toContainElement(screen.getByText('body'))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('renders with base article sx when sx is omitted', () => {
|
|
31
|
+
render(
|
|
32
|
+
<ChronogroveThemeProvider theme={chronogroveTheme}>
|
|
33
|
+
<ArticleColumnContainer data-testid='ac-base'>
|
|
34
|
+
<span>only-base</span>
|
|
35
|
+
</ArticleColumnContainer>
|
|
36
|
+
</ChronogroveThemeProvider>
|
|
37
|
+
)
|
|
38
|
+
expect(screen.getByTestId('ac-base')).toContainElement(screen.getByText('only-base'))
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, Grid } from '@theme-ui/components'
|
|
3
|
+
|
|
4
|
+
/** Theme UI `Grid` `columns` for the home dashboard: single column until `md`, then sidebar + main. */
|
|
5
|
+
export const homeDashboardGridColumns = [
|
|
6
|
+
null,
|
|
7
|
+
null,
|
|
8
|
+
'minmax(200px, 0.375fr) minmax(0, 1.625fr)',
|
|
9
|
+
'minmax(200px, 0.4fr) minmax(0, 1.6fr)'
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
export const homeDashboardGridGap = [null, 4]
|
|
13
|
+
|
|
14
|
+
/** Default aside spacing; merge with site-specific `sx` (e.g. sticky side nav in demos). */
|
|
15
|
+
export const homeDashboardAsideSx = {
|
|
16
|
+
mb: [4, null]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Rounded “glass” panel shell around the main home column (matches gatsby-theme-chronogrove home). */
|
|
20
|
+
export const homeDashboardMainShellSx = {
|
|
21
|
+
position: 'relative',
|
|
22
|
+
borderTopRightRadius: '3em',
|
|
23
|
+
borderTopLeftRadius: '.5em',
|
|
24
|
+
px: [3, 4],
|
|
25
|
+
pt: [2, 3]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const homeDashboardMainInnerMaxWidthSx = {
|
|
29
|
+
maxWidth: '1200px'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Outer stack below the page chrome on the home template (`minHeight`, top padding). */
|
|
33
|
+
export const homeDashboardPageOuterSx = {
|
|
34
|
+
minHeight: '500px',
|
|
35
|
+
pt: 3,
|
|
36
|
+
px: 0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Two-column home dashboard grid (sidebar + main). Pass full `main` node so callers can set
|
|
41
|
+
* `role="main"`, skip-nav targets, footer, and microformats in one place.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} props
|
|
44
|
+
* @param {React.ReactNode} props.aside
|
|
45
|
+
* @param {React.ReactNode} props.main
|
|
46
|
+
* @param {import('theme-ui').ThemeUIStyleObject} [props.asideSx] merged after {@link homeDashboardAsideSx}
|
|
47
|
+
* @param {React.ComponentProps<typeof Grid>} [props.gridProps] forwarded to `Grid`
|
|
48
|
+
*/
|
|
49
|
+
export function HomeDashboardGrid({ aside, main, asideSx = {}, gridProps = {}, ...gridRest }) {
|
|
50
|
+
return (
|
|
51
|
+
<Grid columns={homeDashboardGridColumns} gap={homeDashboardGridGap} {...gridProps} {...gridRest}>
|
|
52
|
+
<Box as='aside' sx={{ ...homeDashboardAsideSx, ...asideSx }}>
|
|
53
|
+
{aside}
|
|
54
|
+
</Box>
|
|
55
|
+
{main}
|
|
56
|
+
</Grid>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { ChronogroveThemeProvider } from './provider.js'
|
|
4
|
+
import chronogroveTheme from './theme.js'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
HomeDashboardGrid,
|
|
8
|
+
homeDashboardAsideSx,
|
|
9
|
+
homeDashboardGridColumns,
|
|
10
|
+
homeDashboardGridGap,
|
|
11
|
+
homeDashboardMainInnerMaxWidthSx,
|
|
12
|
+
homeDashboardMainShellSx,
|
|
13
|
+
homeDashboardPageOuterSx
|
|
14
|
+
} from './home-dashboard-layout.js'
|
|
15
|
+
|
|
16
|
+
describe('home dashboard layout tokens', () => {
|
|
17
|
+
it('exports grid columns and gap used by the Gatsby home template', () => {
|
|
18
|
+
expect(homeDashboardGridColumns[2]).toContain('minmax(200px')
|
|
19
|
+
expect(homeDashboardGridGap).toEqual([null, 4])
|
|
20
|
+
expect(homeDashboardAsideSx).toEqual({ mb: [4, null] })
|
|
21
|
+
expect(homeDashboardMainShellSx.borderTopRightRadius).toBe('3em')
|
|
22
|
+
expect(homeDashboardMainInnerMaxWidthSx.maxWidth).toBe('1200px')
|
|
23
|
+
expect(homeDashboardPageOuterSx.minHeight).toBe('500px')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('HomeDashboardGrid', () => {
|
|
28
|
+
it('renders aside and main slots', () => {
|
|
29
|
+
render(
|
|
30
|
+
<ChronogroveThemeProvider theme={chronogroveTheme}>
|
|
31
|
+
<HomeDashboardGrid aside={<span>nav</span>} main={<div data-testid='main'>content</div>} />
|
|
32
|
+
</ChronogroveThemeProvider>
|
|
33
|
+
)
|
|
34
|
+
expect(screen.getByText('nav')).toBeInTheDocument()
|
|
35
|
+
expect(screen.getByTestId('main')).toHaveTextContent('content')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('merges asideSx onto the aside', () => {
|
|
39
|
+
const { container } = render(
|
|
40
|
+
<ChronogroveThemeProvider theme={chronogroveTheme}>
|
|
41
|
+
<HomeDashboardGrid aside={<span>a</span>} main={<span>b</span>} asideSx={{ position: 'sticky' }} />
|
|
42
|
+
</ChronogroveThemeProvider>
|
|
43
|
+
)
|
|
44
|
+
const aside = container.querySelector('aside')
|
|
45
|
+
expect(aside).toBeTruthy()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('forwards gridProps to Grid', () => {
|
|
49
|
+
const { container } = render(
|
|
50
|
+
<ChronogroveThemeProvider theme={chronogroveTheme}>
|
|
51
|
+
<HomeDashboardGrid
|
|
52
|
+
aside={<span>l</span>}
|
|
53
|
+
main={<span>r</span>}
|
|
54
|
+
gridProps={{ 'data-testid': 'hdg', id: 'home-dash-grid' }}
|
|
55
|
+
/>
|
|
56
|
+
</ChronogroveThemeProvider>
|
|
57
|
+
)
|
|
58
|
+
expect(container.querySelector('#home-dash-grid')).toBeTruthy()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box } from '@theme-ui/components'
|
|
3
|
+
|
|
4
|
+
/** Default cap on thumbnails shown when `images` exceeds this count. */
|
|
5
|
+
export const IMAGE_THUMBNAILS_DEFAULT_MAX = 4
|
|
6
|
+
|
|
7
|
+
/** Thumbnail box size (px); retina-friendly optimizers typically use ~2× for width/height. */
|
|
8
|
+
export const IMAGE_THUMBNAILS_SIZE_PX = 64
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pass-through optimizer (CDN-agnostic default).
|
|
12
|
+
*
|
|
13
|
+
* @param {string | null | undefined} src
|
|
14
|
+
* @returns {string | null | undefined}
|
|
15
|
+
*/
|
|
16
|
+
export const IMAGE_THUMBNAILS_DEFAULT_OPTIMIZE_SRC = src => src
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Horizontal row of small circular image previews — e.g. post cards (`Recap`).
|
|
20
|
+
* Supply `optimizeSrc` for CDN-specific resizing (Gatsby theme uses a Cloudinary helper).
|
|
21
|
+
*
|
|
22
|
+
* @param {object} props
|
|
23
|
+
* @param {Array<string | null | undefined>} [props.images]
|
|
24
|
+
* @param {number} [props.maxImages]
|
|
25
|
+
* @param {(src: string) => string | null | undefined} [props.optimizeSrc]
|
|
26
|
+
*/
|
|
27
|
+
const ImageThumbnails = ({
|
|
28
|
+
images = [],
|
|
29
|
+
maxImages = IMAGE_THUMBNAILS_DEFAULT_MAX,
|
|
30
|
+
optimizeSrc = IMAGE_THUMBNAILS_DEFAULT_OPTIMIZE_SRC
|
|
31
|
+
}) => {
|
|
32
|
+
if (!images || images.length === 0) {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const displayImages = images.slice(0, maxImages)
|
|
37
|
+
const size = IMAGE_THUMBNAILS_SIZE_PX
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Box
|
|
41
|
+
sx={{
|
|
42
|
+
display: 'flex',
|
|
43
|
+
gap: 2,
|
|
44
|
+
mb: 2,
|
|
45
|
+
flexWrap: 'wrap'
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
{displayImages.map((src, index) => {
|
|
49
|
+
const input = typeof src === 'string' ? src : ''
|
|
50
|
+
const optimized = optimizeSrc(input)
|
|
51
|
+
const url = optimized !== null && optimized !== undefined ? String(optimized) : ''
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Box
|
|
55
|
+
key={index}
|
|
56
|
+
sx={{
|
|
57
|
+
width: `${size}px`,
|
|
58
|
+
height: `${size}px`,
|
|
59
|
+
borderRadius: '50%',
|
|
60
|
+
overflow: 'hidden',
|
|
61
|
+
flexShrink: 0,
|
|
62
|
+
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
|
63
|
+
border: '2px solid',
|
|
64
|
+
borderColor: 'background',
|
|
65
|
+
transform: `translateY(${index % 2 === 0 ? 0 : 5}px)`
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<Box
|
|
69
|
+
sx={{
|
|
70
|
+
width: '100%',
|
|
71
|
+
height: '100%',
|
|
72
|
+
...(url
|
|
73
|
+
? {
|
|
74
|
+
backgroundImage: `url(${url})`,
|
|
75
|
+
backgroundSize: 'cover',
|
|
76
|
+
backgroundPosition: 'center',
|
|
77
|
+
backgroundRepeat: 'no-repeat'
|
|
78
|
+
}
|
|
79
|
+
: {})
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
</Box>
|
|
83
|
+
)
|
|
84
|
+
})}
|
|
85
|
+
</Box>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default ImageThumbnails
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { render } from '@testing-library/react'
|
|
2
|
+
import { ThemeUIProvider } from 'theme-ui'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
|
|
5
|
+
import ImageThumbnails from './image-thumbnails.js'
|
|
6
|
+
|
|
7
|
+
const stubTheme = { colors: { background: '#fff' } }
|
|
8
|
+
|
|
9
|
+
const wrap = ui => render(<ThemeUIProvider theme={stubTheme}>{ui}</ThemeUIProvider>)
|
|
10
|
+
|
|
11
|
+
describe('ImageThumbnails', () => {
|
|
12
|
+
const sampleImages = [
|
|
13
|
+
'https://example.com/image1.jpg',
|
|
14
|
+
'https://example.com/image2.jpg',
|
|
15
|
+
'https://example.com/image3.jpg',
|
|
16
|
+
'https://example.com/image4.jpg',
|
|
17
|
+
'https://example.com/image5.jpg'
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
it('renders thumbnails when images are provided', () => {
|
|
21
|
+
const { container } = wrap(<ImageThumbnails images={sampleImages} />)
|
|
22
|
+
|
|
23
|
+
const wrapper = container.firstChild
|
|
24
|
+
expect(wrapper).toBeTruthy()
|
|
25
|
+
expect(wrapper.children).toHaveLength(4)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('renders correct number of thumbnails based on maxImages prop', () => {
|
|
29
|
+
const { container } = wrap(<ImageThumbnails images={sampleImages} maxImages={3} />)
|
|
30
|
+
|
|
31
|
+
const wrapper = container.firstChild
|
|
32
|
+
expect(wrapper).toBeTruthy()
|
|
33
|
+
expect(wrapper.children).toHaveLength(3)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns null when images array is empty', () => {
|
|
37
|
+
const { container } = wrap(<ImageThumbnails images={[]} />)
|
|
38
|
+
expect(container.firstChild).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns null when images is null', () => {
|
|
42
|
+
const { container } = wrap(<ImageThumbnails images={null} />)
|
|
43
|
+
expect(container.firstChild).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns null when images is undefined', () => {
|
|
47
|
+
const { container } = wrap(<ImageThumbnails />)
|
|
48
|
+
expect(container.firstChild).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('renders fewer thumbnails when fewer images are provided than maxImages', () => {
|
|
52
|
+
const twoImages = ['https://example.com/image1.jpg', 'https://example.com/image2.jpg']
|
|
53
|
+
const { container } = wrap(<ImageThumbnails images={twoImages} maxImages={4} />)
|
|
54
|
+
|
|
55
|
+
const wrapper = container.firstChild
|
|
56
|
+
expect(wrapper).toBeTruthy()
|
|
57
|
+
expect(wrapper.children).toHaveLength(2)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('uses optimizeSrc when provided', () => {
|
|
61
|
+
const optimizer = jest.fn(s => `${s}?x=1`)
|
|
62
|
+
const { container } = wrap(<ImageThumbnails images={['https://example.com/a.jpg']} optimizeSrc={optimizer} />)
|
|
63
|
+
expect(optimizer).toHaveBeenCalledWith('https://example.com/a.jpg')
|
|
64
|
+
expect(container.firstChild).toBeTruthy()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('omits inner background when optimizeSrc returns null', () => {
|
|
68
|
+
const { container } = wrap(<ImageThumbnails images={['https://example.com/z.jpg']} optimizeSrc={() => null} />)
|
|
69
|
+
expect(container.firstChild?.firstChild?.firstChild).toBeTruthy()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('omits inner background when optimizeSrc returns undefined', () => {
|
|
73
|
+
const { container } = wrap(<ImageThumbnails images={['https://example.com/z.jpg']} optimizeSrc={() => undefined} />)
|
|
74
|
+
expect(container.firstChild?.firstChild?.firstChild).toBeTruthy()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('handles null and non-string slots as empty thumbnails (same slot count)', () => {
|
|
78
|
+
const mixedImages = ['https://example.com/image1.jpg', null, 'https://example.com/image2.jpg', undefined]
|
|
79
|
+
const { container } = wrap(<ImageThumbnails images={mixedImages} maxImages={4} />)
|
|
80
|
+
|
|
81
|
+
const wrapper = container.firstChild
|
|
82
|
+
expect(wrapper).toBeTruthy()
|
|
83
|
+
expect(wrapper.children).toHaveLength(4)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('matches snapshot with default props', () => {
|
|
87
|
+
const { asFragment } = wrap(<ImageThumbnails images={sampleImages} />)
|
|
88
|
+
expect(asFragment()).toMatchSnapshot()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('matches snapshot with custom maxImages', () => {
|
|
92
|
+
const { asFragment } = wrap(<ImageThumbnails images={sampleImages} maxImages={2} />)
|
|
93
|
+
expect(asFragment()).toMatchSnapshot()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box } from '@theme-ui/components'
|
|
3
|
+
|
|
4
|
+
import { SkipNavContent, SkipNavLink } from './skip-nav/index.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default page chrome: skip link, optional header and footer slots, and a main landmark (unless disabled).
|
|
8
|
+
* Wire Gatsby-specific nav/footer and Zustand-driven `paddingBottom` from the site package.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} props
|
|
11
|
+
* @param {React.ReactNode} props.children
|
|
12
|
+
* @param {boolean} [props.disableMainWrapper]
|
|
13
|
+
* @param {boolean} [props.hideHeader] when true, `header` is not rendered
|
|
14
|
+
* @param {boolean} [props.hideFooter] when true, `footer` is not rendered
|
|
15
|
+
* @param {boolean} [props.transparentBackground]
|
|
16
|
+
* @param {number|string} [props.paddingBottom] bottom padding (e.g. room for a fixed audio bar)
|
|
17
|
+
* @param {React.ReactNode} [props.header] placed inside `<header role="banner">` when not hidden
|
|
18
|
+
* @param {React.ReactNode} [props.footer]
|
|
19
|
+
*/
|
|
20
|
+
export function ChronogrovePageShell({
|
|
21
|
+
children,
|
|
22
|
+
disableMainWrapper = false,
|
|
23
|
+
hideHeader = false,
|
|
24
|
+
hideFooter = false,
|
|
25
|
+
transparentBackground = false,
|
|
26
|
+
paddingBottom = 0,
|
|
27
|
+
header = null,
|
|
28
|
+
footer = null
|
|
29
|
+
}) {
|
|
30
|
+
return (
|
|
31
|
+
<Box
|
|
32
|
+
sx={{
|
|
33
|
+
backgroundColor: transparentBackground ? 'transparent' : 'background',
|
|
34
|
+
display: 'flex',
|
|
35
|
+
flexDirection: 'column',
|
|
36
|
+
flexGrow: 1,
|
|
37
|
+
color: theme => theme?.colors?.text,
|
|
38
|
+
pb: paddingBottom
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<SkipNavLink />
|
|
42
|
+
|
|
43
|
+
{!hideHeader && header ? <header role='banner'>{header}</header> : null}
|
|
44
|
+
|
|
45
|
+
{disableMainWrapper ? (
|
|
46
|
+
children
|
|
47
|
+
) : (
|
|
48
|
+
<Box as='main' role='main'>
|
|
49
|
+
<SkipNavContent />
|
|
50
|
+
{children}
|
|
51
|
+
</Box>
|
|
52
|
+
)}
|
|
53
|
+
|
|
54
|
+
{!hideFooter && footer ? footer : null}
|
|
55
|
+
</Box>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { ChronogroveThemeProvider } from './provider.js'
|
|
4
|
+
import chronogroveTheme from './theme.js'
|
|
5
|
+
|
|
6
|
+
import { ChronogrovePageShell } from './page-shell-layout.js'
|
|
7
|
+
|
|
8
|
+
function shellRender(ui) {
|
|
9
|
+
return render(<ChronogroveThemeProvider theme={chronogroveTheme}>{ui}</ChronogroveThemeProvider>)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('ChronogrovePageShell', () => {
|
|
13
|
+
it('renders skip link, header, main with skip target, children, and footer', () => {
|
|
14
|
+
shellRender(
|
|
15
|
+
<ChronogrovePageShell header={<span>hdr</span>} footer={<span>ftr</span>} paddingBottom='12px'>
|
|
16
|
+
<span>inner</span>
|
|
17
|
+
</ChronogrovePageShell>
|
|
18
|
+
)
|
|
19
|
+
expect(screen.getByText('inner')).toBeInTheDocument()
|
|
20
|
+
expect(screen.getByText('hdr')).toBeInTheDocument()
|
|
21
|
+
expect(screen.getByText('ftr')).toBeInTheDocument()
|
|
22
|
+
expect(document.querySelector('main[role="main"]')).toBeTruthy()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('omits header when hideHeader', () => {
|
|
26
|
+
shellRender(
|
|
27
|
+
<ChronogrovePageShell hideHeader header={<span>no</span>} footer={null}>
|
|
28
|
+
<span>x</span>
|
|
29
|
+
</ChronogrovePageShell>
|
|
30
|
+
)
|
|
31
|
+
expect(screen.queryByText('no')).not.toBeInTheDocument()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('omits footer when hideFooter', () => {
|
|
35
|
+
shellRender(
|
|
36
|
+
<ChronogrovePageShell hideFooter footer={<span>no</span>}>
|
|
37
|
+
<span>x</span>
|
|
38
|
+
</ChronogrovePageShell>
|
|
39
|
+
)
|
|
40
|
+
expect(screen.queryByText('no')).not.toBeInTheDocument()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('renders children without main wrapper when disableMainWrapper', () => {
|
|
44
|
+
shellRender(
|
|
45
|
+
<ChronogrovePageShell disableMainWrapper>
|
|
46
|
+
<span data-testid='bare'>bare</span>
|
|
47
|
+
</ChronogrovePageShell>
|
|
48
|
+
)
|
|
49
|
+
expect(screen.getByTestId('bare')).toBeInTheDocument()
|
|
50
|
+
expect(document.querySelector('main[role="main"]')).toBeFalsy()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('uses transparent background when requested', () => {
|
|
54
|
+
const { container } = shellRender(
|
|
55
|
+
<ChronogrovePageShell transparentBackground>
|
|
56
|
+
<span>t</span>
|
|
57
|
+
</ChronogrovePageShell>
|
|
58
|
+
)
|
|
59
|
+
expect(container.firstChild).toBeTruthy()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('does not render a banner when header is null', () => {
|
|
63
|
+
shellRender(
|
|
64
|
+
<ChronogrovePageShell header={null}>
|
|
65
|
+
<span>no-banner</span>
|
|
66
|
+
</ChronogrovePageShell>
|
|
67
|
+
)
|
|
68
|
+
expect(screen.getByText('no-banner')).toBeInTheDocument()
|
|
69
|
+
expect(document.querySelector('header[role="banner"]')).toBeFalsy()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box } from '@theme-ui/components'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compact vertical strip of thumbnail images — staggered/offset for card side rails.
|
|
6
|
+
*/
|
|
7
|
+
const ThumbnailStrip = ({ images = [], maxImages = 4, size = 36 }) => {
|
|
8
|
+
if (!images || images.length === 0) {
|
|
9
|
+
return null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const displayImages = images.slice(0, maxImages)
|
|
13
|
+
const overlap = size * 0.35 // 35% overlap for compact stacking
|
|
14
|
+
const stripW = size + 8
|
|
15
|
+
const stripH = displayImages.length * (size - overlap) + overlap
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<Box
|
|
19
|
+
sx={{
|
|
20
|
+
display: 'flex',
|
|
21
|
+
flexDirection: 'column',
|
|
22
|
+
alignItems: 'center',
|
|
23
|
+
position: 'relative',
|
|
24
|
+
// Theme UI maps bare numbers in `sx` to theme scales — use explicit px for layout math.
|
|
25
|
+
width: `${stripW}px`,
|
|
26
|
+
height: `${stripH}px`,
|
|
27
|
+
flexShrink: 0
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
{displayImages.map((src, index) => (
|
|
31
|
+
<Box
|
|
32
|
+
key={index}
|
|
33
|
+
sx={{
|
|
34
|
+
position: 'absolute',
|
|
35
|
+
top: `${index * (size - overlap)}px`,
|
|
36
|
+
left: index % 2 === 0 ? 0 : '8px',
|
|
37
|
+
width: `${size}px`,
|
|
38
|
+
height: `${size}px`,
|
|
39
|
+
borderRadius: '6px',
|
|
40
|
+
overflow: 'hidden',
|
|
41
|
+
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.12)',
|
|
42
|
+
border: '2px solid',
|
|
43
|
+
borderColor: 'background',
|
|
44
|
+
transition: 'transform 0.2s ease',
|
|
45
|
+
zIndex: displayImages.length - index,
|
|
46
|
+
'&:hover': {
|
|
47
|
+
transform: 'scale(1.08)',
|
|
48
|
+
zIndex: displayImages.length + 1
|
|
49
|
+
}
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
<Box
|
|
53
|
+
sx={{
|
|
54
|
+
width: '100%',
|
|
55
|
+
height: '100%',
|
|
56
|
+
...(typeof src === 'string' && src
|
|
57
|
+
? {
|
|
58
|
+
backgroundImage: `url(${src})`,
|
|
59
|
+
backgroundSize: 'cover',
|
|
60
|
+
backgroundPosition: 'center',
|
|
61
|
+
backgroundRepeat: 'no-repeat'
|
|
62
|
+
}
|
|
63
|
+
: {})
|
|
64
|
+
}}
|
|
65
|
+
/>
|
|
66
|
+
</Box>
|
|
67
|
+
))}
|
|
68
|
+
</Box>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default ThumbnailStrip
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { render } from '@testing-library/react'
|
|
2
|
+
import { ThemeUIProvider } from 'theme-ui'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
|
|
5
|
+
import ThumbnailStrip from './thumbnail-strip.js'
|
|
6
|
+
|
|
7
|
+
const stubTheme = { colors: { background: '#fff' } }
|
|
8
|
+
|
|
9
|
+
const wrap = ui => render(<ThemeUIProvider theme={stubTheme}>{ui}</ThemeUIProvider>)
|
|
10
|
+
|
|
11
|
+
describe('ThumbnailStrip', () => {
|
|
12
|
+
const sampleImages = [
|
|
13
|
+
'https://example.com/image1.jpg',
|
|
14
|
+
'https://example.com/image2.jpg',
|
|
15
|
+
'https://example.com/image3.jpg',
|
|
16
|
+
'https://example.com/image4.jpg',
|
|
17
|
+
'https://example.com/image5.jpg'
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
it('renders thumbnails when images are provided', () => {
|
|
21
|
+
const { container } = wrap(<ThumbnailStrip images={sampleImages} />)
|
|
22
|
+
|
|
23
|
+
const wrapper = container.firstChild
|
|
24
|
+
expect(wrapper).toBeTruthy()
|
|
25
|
+
expect(wrapper.children).toHaveLength(4)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('renders correct number of thumbnails based on maxImages prop', () => {
|
|
29
|
+
const { container } = wrap(<ThumbnailStrip images={sampleImages} maxImages={3} />)
|
|
30
|
+
|
|
31
|
+
const wrapper = container.firstChild
|
|
32
|
+
expect(wrapper).toBeTruthy()
|
|
33
|
+
expect(wrapper.children).toHaveLength(3)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns null when images array is empty', () => {
|
|
37
|
+
const { container } = wrap(<ThumbnailStrip images={[]} />)
|
|
38
|
+
expect(container.firstChild).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns null when images is null', () => {
|
|
42
|
+
const { container } = wrap(<ThumbnailStrip images={null} />)
|
|
43
|
+
expect(container.firstChild).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns null when images is undefined', () => {
|
|
47
|
+
const { container } = wrap(<ThumbnailStrip />)
|
|
48
|
+
expect(container.firstChild).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('renders fewer thumbnails when fewer images are provided than maxImages', () => {
|
|
52
|
+
const twoImages = ['https://example.com/image1.jpg', 'https://example.com/image2.jpg']
|
|
53
|
+
const { container } = wrap(<ThumbnailStrip images={twoImages} maxImages={4} />)
|
|
54
|
+
|
|
55
|
+
const wrapper = container.firstChild
|
|
56
|
+
expect(wrapper).toBeTruthy()
|
|
57
|
+
expect(wrapper.children).toHaveLength(2)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('accepts custom size prop', () => {
|
|
61
|
+
const { container } = wrap(<ThumbnailStrip images={sampleImages} size={50} />)
|
|
62
|
+
|
|
63
|
+
const wrapper = container.firstChild
|
|
64
|
+
expect(wrapper).toBeTruthy()
|
|
65
|
+
expect(wrapper.children).toHaveLength(4)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('omits background when an entry is not a non-empty string', () => {
|
|
69
|
+
const mixed = ['https://example.com/a.jpg', '', null]
|
|
70
|
+
const { container } = wrap(<ThumbnailStrip images={mixed} maxImages={4} />)
|
|
71
|
+
expect(container.firstChild?.children?.length).toBe(3)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('matches snapshot with default props', () => {
|
|
75
|
+
const { asFragment } = wrap(<ThumbnailStrip images={sampleImages} />)
|
|
76
|
+
expect(asFragment()).toMatchSnapshot()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('matches snapshot with custom maxImages and size', () => {
|
|
80
|
+
const { asFragment } = wrap(<ThumbnailStrip images={sampleImages} maxImages={2} size={48} />)
|
|
81
|
+
expect(asFragment()).toMatchSnapshot()
|
|
82
|
+
})
|
|
83
|
+
})
|