@chronogrove/ui 0.83.0 → 0.83.2
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 +13 -1
- package/src/__snapshots__/theme.spec.js.snap +10 -10
- package/src/__snapshots__/widget-header.spec.js.snap +21 -16
- package/src/article-column-container.js +23 -0
- package/src/article-column-container.spec.js +40 -0
- package/src/color-utils.js +27 -2
- package/src/color-utils.spec.js +34 -1
- package/src/home-dashboard-layout.js +58 -0
- package/src/home-dashboard-layout.spec.js +60 -0
- package/src/page-shell-layout.js +57 -0
- package/src/page-shell-layout.spec.js +71 -0
- package/src/theme.js +7 -4
- package/src/widget-header.js +152 -63
- package/src/widget-header.spec.js +69 -13
- package/src/widget-section.js +2 -1
- package/src/widget-section.spec.js +32 -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
|
|
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`** (dashboard `<section>`; when **`id`** is passed, **`tabIndex={-1}`** defaults so hosts can **`focus`** the landmark after hash / in-page navigation), **`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.83.
|
|
3
|
+
"version": "0.83.2",
|
|
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,18 @@
|
|
|
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
|
+
},
|
|
141
153
|
"./thumbnail-strip": {
|
|
142
154
|
"import": "./src/thumbnail-strip.js",
|
|
143
155
|
"default": "./src/thumbnail-strip.js"
|
|
@@ -115,8 +115,8 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
|
|
|
115
115
|
"paddingLeft": "8px",
|
|
116
116
|
},
|
|
117
117
|
"&:hover, &:focus": {
|
|
118
|
-
"boxShadow": "
|
|
119
|
-
"transform": "scale(1.
|
|
118
|
+
"boxShadow": "0 1px 0 rgba(0,0,0,0.06), 0 2px 0 rgba(0,0,0,0.04), 0 3px 0 rgba(0,0,0,0.03), 0 8px 20px rgba(66,46,163,0.14)",
|
|
119
|
+
"transform": "translateY(-3px) scale(1.008)",
|
|
120
120
|
},
|
|
121
121
|
".card-media": {
|
|
122
122
|
"mb": 2,
|
|
@@ -145,7 +145,7 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
|
|
|
145
145
|
"height": "100%",
|
|
146
146
|
"padding": 3,
|
|
147
147
|
"textDecoration": "none",
|
|
148
|
-
"transition": "
|
|
148
|
+
"transition": "transform 200ms ease-in-out, box-shadow 200ms ease-in-out",
|
|
149
149
|
},
|
|
150
150
|
"StatusCardDark": {
|
|
151
151
|
"backgroundColor": "#1e2530",
|
|
@@ -175,8 +175,8 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
|
|
|
175
175
|
},
|
|
176
176
|
"actionCard": {
|
|
177
177
|
"&:hover, &:focus": {
|
|
178
|
-
"boxShadow": "
|
|
179
|
-
"transform": "scale(1.
|
|
178
|
+
"boxShadow": "0 1px 0 rgba(0,0,0,0.06), 0 2px 0 rgba(0,0,0,0.04), 0 3px 0 rgba(0,0,0,0.03), 0 8px 20px rgba(66,46,163,0.14)",
|
|
179
|
+
"transform": "translateY(-3px) scale(1.008)",
|
|
180
180
|
},
|
|
181
181
|
"WebkitBackdropFilter": "blur(10px)",
|
|
182
182
|
"a": {
|
|
@@ -196,7 +196,7 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
|
|
|
196
196
|
],
|
|
197
197
|
"padding": 3,
|
|
198
198
|
"textDecoration": "none",
|
|
199
|
-
"transition": "
|
|
199
|
+
"transition": "transform 200ms ease-in-out, box-shadow 200ms ease-in-out",
|
|
200
200
|
},
|
|
201
201
|
"aiSummary": {
|
|
202
202
|
"@keyframes blink": {
|
|
@@ -356,7 +356,7 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
|
|
|
356
356
|
"backdropFilter": "blur(12px) saturate(150%)",
|
|
357
357
|
"bg": "panel-background",
|
|
358
358
|
"borderRadius": "card",
|
|
359
|
-
"boxShadow": "
|
|
359
|
+
"boxShadow": "0 1px 0 rgba(0,0,0,0.06), 0 2px 0 rgba(0,0,0,0.04), 0 3px 0 rgba(0,0,0,0.03), 0 4px 12px rgba(66,46,163,0.08)",
|
|
360
360
|
"color": "text",
|
|
361
361
|
"flexGrow": 1,
|
|
362
362
|
"fontSize": [
|
|
@@ -762,8 +762,8 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
|
|
|
762
762
|
},
|
|
763
763
|
"InstagramItem": {
|
|
764
764
|
"&:hover, &:focus": {
|
|
765
|
-
"boxShadow": "
|
|
766
|
-
"transform": "scale(1.
|
|
765
|
+
"boxShadow": "0 1px 0 rgba(0,0,0,0.06), 0 2px 0 rgba(0,0,0,0.04), 0 3px 0 rgba(0,0,0,0.03), 0 8px 20px rgba(66,46,163,0.14)",
|
|
766
|
+
"transform": "translateY(-3px) scale(1.008)",
|
|
767
767
|
},
|
|
768
768
|
"background": "none",
|
|
769
769
|
"border": "none",
|
|
@@ -772,7 +772,7 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
|
|
|
772
772
|
"cursor": "pointer",
|
|
773
773
|
"overflow": "hidden",
|
|
774
774
|
"p": 0,
|
|
775
|
-
"transition": "
|
|
775
|
+
"transition": "transform 200ms ease-in-out, box-shadow 200ms ease-in-out",
|
|
776
776
|
},
|
|
777
777
|
"IntroExperienceSlide": {
|
|
778
778
|
"&.active-slide": {
|
|
@@ -3,29 +3,34 @@
|
|
|
3
3
|
exports[`WidgetHeader matches the snapshot 1`] = `
|
|
4
4
|
<DocumentFragment>
|
|
5
5
|
<header
|
|
6
|
-
class="css-
|
|
6
|
+
class="css-ubozo4"
|
|
7
7
|
>
|
|
8
8
|
<div
|
|
9
|
-
class="css-
|
|
9
|
+
class="css-3vwaby"
|
|
10
10
|
>
|
|
11
|
-
<
|
|
12
|
-
|
|
11
|
+
<div
|
|
12
|
+
aria-hidden="true"
|
|
13
|
+
class="css-1muvmnm"
|
|
13
14
|
>
|
|
14
15
|
<span
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
data-testid="fa-icon"
|
|
21
|
-
/>
|
|
22
|
-
</span>
|
|
23
|
-
Neat & Interesting Widget
|
|
24
|
-
</h2>
|
|
16
|
+
aria-hidden="true"
|
|
17
|
+
data-icon="spotify"
|
|
18
|
+
data-testid="fa-icon"
|
|
19
|
+
/>
|
|
20
|
+
</div>
|
|
25
21
|
<div
|
|
26
|
-
class="
|
|
22
|
+
class="css-m8374l"
|
|
27
23
|
>
|
|
28
|
-
|
|
24
|
+
<h2
|
|
25
|
+
class="css-1tfs9qy"
|
|
26
|
+
>
|
|
27
|
+
Neat & Interesting Widget
|
|
28
|
+
</h2>
|
|
29
|
+
<div
|
|
30
|
+
class="sidebar-content"
|
|
31
|
+
>
|
|
32
|
+
Sidebar
|
|
33
|
+
</div>
|
|
29
34
|
</div>
|
|
30
35
|
</div>
|
|
31
36
|
</header>
|
|
@@ -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
|
+
})
|
package/src/color-utils.js
CHANGED
|
@@ -6,13 +6,38 @@
|
|
|
6
6
|
/** Fallback RGB string when hex is invalid (blue) */
|
|
7
7
|
const HEX_TO_RGB_FALLBACK = '74, 158, 255'
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Normalizes #rgb / #rgba shorthand, #rrggbb, or #rrggbbaa to #rrggbb for RGB extraction.
|
|
11
|
+
* Appending two hex digits to 3-digit theme colors (e.g. `#111` + `22`) is not valid CSS.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} hex
|
|
14
|
+
* @returns {string|null} `#rrggbb` or null if not parseable as hex
|
|
15
|
+
*/
|
|
16
|
+
export const normalizeHexToRrggbb = hex => {
|
|
17
|
+
if (typeof hex !== 'string') return null
|
|
18
|
+
const raw = hex.trim().replace(/^#/, '')
|
|
19
|
+
if (!raw || !/^[a-f\d]+$/i.test(raw)) return null
|
|
20
|
+
if (raw.length === 3) {
|
|
21
|
+
return `#${[...raw].map(c => c + c).join('')}`
|
|
22
|
+
}
|
|
23
|
+
if (raw.length === 4) {
|
|
24
|
+
const [r, g, b] = raw
|
|
25
|
+
return `#${r}${r}${g}${g}${b}${b}`
|
|
26
|
+
}
|
|
27
|
+
if (raw.length === 6) return `#${raw}`
|
|
28
|
+
if (raw.length === 8) return `#${raw.slice(0, 6)}`
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
9
32
|
/**
|
|
10
33
|
* Converts hex color to RGB string for use in rgba()
|
|
11
|
-
* @param {string} hex - Hex color code (with or without #)
|
|
34
|
+
* @param {string} hex - Hex color code (with or without #); 3- and 4-digit shorthand supported
|
|
12
35
|
* @returns {string} RGB values as comma-separated string (e.g. "66, 46, 163")
|
|
13
36
|
*/
|
|
14
37
|
export const hexToRgb = hex => {
|
|
15
|
-
const
|
|
38
|
+
const rrggbb = normalizeHexToRrggbb(hex)
|
|
39
|
+
if (!rrggbb) return HEX_TO_RGB_FALLBACK
|
|
40
|
+
const result = /^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(rrggbb)
|
|
16
41
|
if (!result) return HEX_TO_RGB_FALLBACK
|
|
17
42
|
return `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}`
|
|
18
43
|
}
|
package/src/color-utils.spec.js
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
|
-
import { hexToRgb, hexToRgba, BUTTON_PRIMARY_COLORS } from './color-utils'
|
|
1
|
+
import { hexToRgb, hexToRgba, normalizeHexToRrggbb, BUTTON_PRIMARY_COLORS } from './color-utils'
|
|
2
|
+
|
|
3
|
+
describe('normalizeHexToRrggbb', () => {
|
|
4
|
+
it('expands 3-digit shorthand', () => {
|
|
5
|
+
expect(normalizeHexToRrggbb('#111')).toBe('#111111')
|
|
6
|
+
expect(normalizeHexToRrggbb('#fff')).toBe('#ffffff')
|
|
7
|
+
expect(normalizeHexToRrggbb('abc')).toBe('#aabbcc')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('expands 4-digit shorthand to RGB only', () => {
|
|
11
|
+
expect(normalizeHexToRrggbb('#f0f8')).toBe('#ff00ff')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('passes through 6-digit and strips alpha from 8-digit', () => {
|
|
15
|
+
expect(normalizeHexToRrggbb('#112233')).toBe('#112233')
|
|
16
|
+
expect(normalizeHexToRrggbb('#11223344')).toBe('#112233')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('returns null for invalid input', () => {
|
|
20
|
+
expect(normalizeHexToRrggbb('')).toBeNull()
|
|
21
|
+
expect(normalizeHexToRrggbb('not-hex')).toBeNull()
|
|
22
|
+
expect(normalizeHexToRrggbb('#12')).toBeNull()
|
|
23
|
+
expect(normalizeHexToRrggbb(null)).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
})
|
|
2
26
|
|
|
3
27
|
describe('hexToRgb', () => {
|
|
4
28
|
it('converts hex with hash to RGB', () => {
|
|
5
29
|
expect(hexToRgb('#422EA3')).toBe('66, 46, 163')
|
|
6
30
|
})
|
|
7
31
|
|
|
32
|
+
it('converts 3-digit shorthand (theme text colors)', () => {
|
|
33
|
+
expect(hexToRgb('#111')).toBe('17, 17, 17')
|
|
34
|
+
expect(hexToRgb('#fff')).toBe('255, 255, 255')
|
|
35
|
+
})
|
|
36
|
+
|
|
8
37
|
it('converts hex without hash to RGB', () => {
|
|
9
38
|
expect(hexToRgb('4a9eff')).toBe('74, 158, 255')
|
|
10
39
|
})
|
|
@@ -23,6 +52,10 @@ describe('hexToRgba', () => {
|
|
|
23
52
|
expect(hexToRgba('#422EA3', 0.5)).toBe('rgba(66, 46, 163, 0.5)')
|
|
24
53
|
})
|
|
25
54
|
|
|
55
|
+
it('supports 3-digit shorthand with fractional alpha', () => {
|
|
56
|
+
expect(hexToRgba('#111', 34 / 255)).toBe('rgba(17, 17, 17, 0.13333333333333333)')
|
|
57
|
+
})
|
|
58
|
+
|
|
26
59
|
it('accepts hex without hash', () => {
|
|
27
60
|
expect(hexToRgba('4a9eff', 1)).toBe('rgba(74, 158, 255, 1)')
|
|
28
61
|
})
|
|
@@ -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,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
|
+
})
|
package/src/theme.js
CHANGED
|
@@ -14,11 +14,12 @@ const fonts = {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export const floatOnHover = {
|
|
17
|
-
transition: '
|
|
17
|
+
transition: 'transform 200ms ease-in-out, box-shadow 200ms ease-in-out',
|
|
18
18
|
|
|
19
19
|
'&:hover, &:focus': {
|
|
20
|
-
transform: 'scale(1.
|
|
21
|
-
boxShadow:
|
|
20
|
+
transform: 'translateY(-3px) scale(1.008)',
|
|
21
|
+
boxShadow:
|
|
22
|
+
'0 1px 0 rgba(0,0,0,0.06), 0 2px 0 rgba(0,0,0,0.04), 0 3px 0 rgba(0,0,0,0.03), 0 8px 20px rgba(66,46,163,0.14)'
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -81,7 +82,9 @@ export const card = {
|
|
|
81
82
|
borderRadius: 'card',
|
|
82
83
|
bg: 'panel-background',
|
|
83
84
|
color: 'text',
|
|
84
|
-
|
|
85
|
+
// Layered paper-stack shadow: each layer is a 1px offset slice, the final spread is the ambient
|
|
86
|
+
boxShadow:
|
|
87
|
+
'0 1px 0 rgba(0,0,0,0.06), 0 2px 0 rgba(0,0,0,0.04), 0 3px 0 rgba(0,0,0,0.03), 0 4px 12px rgba(66,46,163,0.08)',
|
|
85
88
|
flexGrow: 1,
|
|
86
89
|
padding: 3,
|
|
87
90
|
fontSize: [1, 2],
|
package/src/widget-header.js
CHANGED
|
@@ -2,88 +2,177 @@ import React from 'react'
|
|
|
2
2
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
3
3
|
import { Box, Heading } from '@theme-ui/components'
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import { hexToRgba, normalizeHexToRrggbb } from './color-utils.js'
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
flexDirection: ['column', 'row'],
|
|
10
|
-
alignItems: ['center', 'baseline'],
|
|
11
|
-
justifyContent: ['center', 'space-between'],
|
|
12
|
-
gap: [2, 3],
|
|
13
|
-
flexWrap: 'wrap',
|
|
14
|
-
pb: 2,
|
|
15
|
-
// Soft separator: visible but not harsh (gray[4] in theme scale; fallback ~12% black)
|
|
16
|
-
borderBottom: '1px solid',
|
|
17
|
-
borderColor: theme => theme?.colors?.gray?.[4] ?? 'rgba(0,0,0,0.12)'
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Groups headline + CTA so they sit together on the left; metrics stay on the right.
|
|
22
|
-
* Always row so headline + CTA stay on one line at every breakpoint (including mobile).
|
|
23
|
-
*/
|
|
24
|
-
const titleGroupStyles = {
|
|
25
|
-
display: 'flex',
|
|
26
|
-
flexDirection: 'row',
|
|
27
|
-
alignItems: 'baseline',
|
|
28
|
-
gap: [1, 2],
|
|
29
|
-
order: 1
|
|
30
|
-
}
|
|
7
|
+
/** Alpha matching prior intent of appending `22` to 8-digit #RRGGBBAA (34/255) */
|
|
8
|
+
const METRIC_CHIP_BORDER_ALPHA = 34 / 255
|
|
31
9
|
|
|
32
10
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
11
|
+
* WidgetHeader
|
|
12
|
+
*
|
|
13
|
+
* Social-dashboard section headline: a large clean heading paired with square
|
|
14
|
+
* data-chip metrics. No horizontal rules — section identity comes from the
|
|
15
|
+
* colored icon chip and typographic weight alone.
|
|
16
|
+
*
|
|
17
|
+
* Layout:
|
|
18
|
+
* [icon chip] [Heading] [CTA aside] [metrics chips →]
|
|
19
|
+
*
|
|
20
|
+
* The icon chip is a small square badge (accent bg, white icon) that reads like
|
|
21
|
+
* a label on a physical card index tab. Metrics are monospaced square chips:
|
|
22
|
+
* number + unit in a bordered box, no rounded pills.
|
|
37
23
|
*/
|
|
38
|
-
const headingStyles = {
|
|
39
|
-
fontSize: [4, 5],
|
|
40
|
-
display: 'flex',
|
|
41
|
-
alignItems: 'baseline',
|
|
42
|
-
m: 0,
|
|
43
|
-
py: 0,
|
|
44
|
-
pt: 0,
|
|
45
|
-
pb: 0,
|
|
46
|
-
lineHeight: 1
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const metricsStyles = {
|
|
50
|
-
order: 2
|
|
51
|
-
}
|
|
52
|
-
|
|
53
24
|
const WidgetHeader = ({ aside, children, icon, metrics, metricsLoading }) => {
|
|
54
25
|
const hasMetrics = (Array.isArray(metrics) && metrics.length > 0) || metricsLoading
|
|
26
|
+
|
|
27
|
+
// Placeholder chips while loading
|
|
28
|
+
const metricsToShow = metricsLoading ? [{}, {}] : Array.isArray(metrics) ? metrics : []
|
|
29
|
+
|
|
55
30
|
return (
|
|
56
31
|
<Box
|
|
57
32
|
as='header'
|
|
58
33
|
sx={{
|
|
59
|
-
|
|
60
|
-
|
|
34
|
+
display: 'flex',
|
|
35
|
+
flexDirection: ['column', 'row'],
|
|
36
|
+
alignItems: ['flex-start', 'center'],
|
|
37
|
+
justifyContent: ['flex-start', 'space-between'],
|
|
38
|
+
gap: [2, 3],
|
|
39
|
+
flexWrap: 'wrap',
|
|
40
|
+
pb: 3,
|
|
61
41
|
mb: hasMetrics ? 4 : 2
|
|
62
42
|
}}
|
|
63
43
|
>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
44
|
+
{/* Left cluster: icon chip (center-aligned) + heading/CTA sub-row (baseline-aligned) */}
|
|
45
|
+
<Box
|
|
46
|
+
sx={{
|
|
47
|
+
display: 'flex',
|
|
48
|
+
flexDirection: 'row',
|
|
49
|
+
alignItems: 'center',
|
|
50
|
+
gap: 2,
|
|
51
|
+
order: 1,
|
|
52
|
+
minWidth: 0
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
{/* Colored square icon chip — center-aligned with the heading block */}
|
|
56
|
+
{icon && (
|
|
57
|
+
<Box
|
|
58
|
+
aria-hidden='true'
|
|
59
|
+
sx={{
|
|
60
|
+
flexShrink: 0,
|
|
61
|
+
width: '32px',
|
|
62
|
+
height: '32px',
|
|
63
|
+
borderRadius: '6px',
|
|
64
|
+
backgroundColor: 'primary',
|
|
65
|
+
display: 'flex',
|
|
66
|
+
alignItems: 'center',
|
|
67
|
+
justifyContent: 'center',
|
|
68
|
+
boxShadow: theme =>
|
|
69
|
+
`inset 0 -2px 0 rgba(0,0,0,0.2), 0 1px 3px rgba(${theme.colors?.primaryRgb ?? '66,46,163'},0.3)`
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<FontAwesomeIcon icon={icon} style={{ width: 14, height: 14, color: '#fff' }} />
|
|
73
|
+
</Box>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
{/* Heading + CTA aside: baseline-aligned with each other, same as original */}
|
|
77
|
+
<Box
|
|
78
|
+
sx={{
|
|
79
|
+
display: 'flex',
|
|
80
|
+
flexDirection: 'row',
|
|
81
|
+
alignItems: 'baseline',
|
|
82
|
+
gap: [1, 2],
|
|
83
|
+
minWidth: 0
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<Heading
|
|
87
|
+
as='h2'
|
|
88
|
+
sx={{
|
|
89
|
+
fontSize: [4, 5],
|
|
90
|
+
fontFamily: 'heading',
|
|
91
|
+
fontWeight: 'bold',
|
|
92
|
+
lineHeight: 1,
|
|
93
|
+
m: 0,
|
|
94
|
+
p: 0,
|
|
95
|
+
textShadow: '0 1px 0 rgba(255,255,255,0.5), 0 -1px 0 rgba(0,0,0,0.07)',
|
|
96
|
+
letterSpacing: '-0.01em'
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
{children}
|
|
100
|
+
</Heading>
|
|
101
|
+
|
|
102
|
+
{aside}
|
|
103
|
+
</Box>
|
|
104
|
+
</Box>
|
|
105
|
+
|
|
106
|
+
{/* Right cluster: square metric chips */}
|
|
107
|
+
{hasMetrics && (
|
|
108
|
+
<Box
|
|
109
|
+
sx={{
|
|
110
|
+
display: 'flex',
|
|
111
|
+
flexDirection: 'row',
|
|
112
|
+
gap: '6px',
|
|
113
|
+
alignItems: 'center',
|
|
114
|
+
order: 2,
|
|
115
|
+
flexShrink: 0
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
{metricsToShow.map(({ displayName, id, value } = {}, idx) => (
|
|
67
119
|
<Box
|
|
68
|
-
|
|
120
|
+
key={id || idx}
|
|
69
121
|
sx={{
|
|
70
122
|
display: 'inline-flex',
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
123
|
+
flexDirection: 'column',
|
|
124
|
+
alignItems: 'center',
|
|
125
|
+
justifyContent: 'center',
|
|
126
|
+
minWidth: '52px',
|
|
127
|
+
px: '6px',
|
|
128
|
+
py: '4px',
|
|
129
|
+
borderRadius: '4px',
|
|
130
|
+
border: '1px solid',
|
|
131
|
+
// Text-based border: theme `text` is often 3-digit hex (#111 / #fff); appending
|
|
132
|
+
// `22` for alpha is invalid CSS — use rgba() from expanded hex instead.
|
|
133
|
+
borderColor: theme => {
|
|
134
|
+
const text = theme?.colors?.text ?? '#111'
|
|
135
|
+
const base = normalizeHexToRrggbb(text)
|
|
136
|
+
return base ? hexToRgba(base, METRIC_CHIP_BORDER_ALPHA) : 'rgba(17, 17, 17, 0.133)'
|
|
137
|
+
},
|
|
138
|
+
// panel-background is defined for both light + dark in chronogrove-theme-surface-colors
|
|
139
|
+
backgroundColor: 'panel-background',
|
|
140
|
+
...(metricsLoading
|
|
141
|
+
? {
|
|
142
|
+
animation: 'cgPulse 1.4s ease-in-out infinite',
|
|
143
|
+
opacity: 0.5
|
|
144
|
+
}
|
|
145
|
+
: {})
|
|
75
146
|
}}
|
|
76
147
|
>
|
|
77
|
-
<
|
|
148
|
+
<Box
|
|
149
|
+
sx={{
|
|
150
|
+
fontFamily: 'monospace',
|
|
151
|
+
fontSize: '0.8rem',
|
|
152
|
+
fontWeight: 'bold',
|
|
153
|
+
lineHeight: 1.1,
|
|
154
|
+
color: 'primary',
|
|
155
|
+
letterSpacing: '-0.02em'
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{value ?? '—'}
|
|
159
|
+
</Box>
|
|
160
|
+
<Box
|
|
161
|
+
sx={{
|
|
162
|
+
fontFamily: 'heading',
|
|
163
|
+
fontSize: '0.6rem',
|
|
164
|
+
fontWeight: 'normal',
|
|
165
|
+
lineHeight: 1.2,
|
|
166
|
+
color: 'textMuted',
|
|
167
|
+
textTransform: 'uppercase',
|
|
168
|
+
letterSpacing: '0.06em',
|
|
169
|
+
whiteSpace: 'nowrap'
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
{displayName ?? ''}
|
|
173
|
+
</Box>
|
|
78
174
|
</Box>
|
|
79
|
-
)}
|
|
80
|
-
{children}
|
|
81
|
-
</Heading>
|
|
82
|
-
{aside}
|
|
83
|
-
</Box>
|
|
84
|
-
{((Array.isArray(metrics) && metrics.length > 0) || metricsLoading) && (
|
|
85
|
-
<Box sx={metricsStyles}>
|
|
86
|
-
<ProfileMetricsBadge compact isLoading={metricsLoading} metrics={metrics} />
|
|
175
|
+
))}
|
|
87
176
|
</Box>
|
|
88
177
|
)}
|
|
89
178
|
</Box>
|
|
@@ -29,22 +29,25 @@ describe('WidgetHeader', () => {
|
|
|
29
29
|
const metrics = [{ displayName: 'Stars', id: 's', value: 12 }]
|
|
30
30
|
const { getByText } = render(
|
|
31
31
|
<ThemeUIProvider theme={chronogroveTheme}>
|
|
32
|
-
<WidgetHeader metrics={metrics}>
|
|
32
|
+
<WidgetHeader icon={mockIcon} metrics={metrics}>
|
|
33
|
+
Title
|
|
34
|
+
</WidgetHeader>
|
|
33
35
|
</ThemeUIProvider>
|
|
34
36
|
)
|
|
35
|
-
expect(getByText('12
|
|
37
|
+
expect(getByText('12')).toBeInTheDocument()
|
|
38
|
+
expect(getByText('Stars')).toBeInTheDocument()
|
|
36
39
|
})
|
|
37
40
|
|
|
38
41
|
it('renders loading metrics placeholders when metricsLoading', () => {
|
|
39
|
-
|
|
42
|
+
render(
|
|
40
43
|
<ThemeUIProvider theme={chronogroveTheme}>
|
|
41
|
-
<WidgetHeader metrics={[]} metricsLoading>
|
|
44
|
+
<WidgetHeader icon={mockIcon} metrics={[]} metricsLoading>
|
|
42
45
|
Title
|
|
43
46
|
</WidgetHeader>
|
|
44
47
|
</ThemeUIProvider>
|
|
45
48
|
)
|
|
46
|
-
const
|
|
47
|
-
expect(
|
|
49
|
+
const placeholders = screen.getAllByText('—')
|
|
50
|
+
expect(placeholders).toHaveLength(2)
|
|
48
51
|
})
|
|
49
52
|
|
|
50
53
|
it('renders without icon', () => {
|
|
@@ -57,20 +60,73 @@ describe('WidgetHeader', () => {
|
|
|
57
60
|
expect(screen.queryByTestId('fa-icon')).not.toBeInTheDocument()
|
|
58
61
|
})
|
|
59
62
|
|
|
60
|
-
it('
|
|
61
|
-
const
|
|
63
|
+
it('renders metric chips when theme omits gray scale entries', () => {
|
|
64
|
+
const themeMissingGray = {
|
|
62
65
|
...chronogroveTheme,
|
|
63
66
|
colors: {
|
|
64
67
|
...chronogroveTheme.colors,
|
|
65
|
-
gray:
|
|
68
|
+
gray: undefined
|
|
66
69
|
}
|
|
67
70
|
}
|
|
68
|
-
|
|
69
|
-
<ThemeUIProvider theme={
|
|
70
|
-
<WidgetHeader
|
|
71
|
+
render(
|
|
72
|
+
<ThemeUIProvider theme={themeMissingGray}>
|
|
73
|
+
<WidgetHeader icon={mockIcon} metrics={[{ displayName: 'X', id: 'x', value: 1 }]}>
|
|
74
|
+
Border test
|
|
75
|
+
</WidgetHeader>
|
|
71
76
|
</ThemeUIProvider>
|
|
72
77
|
)
|
|
73
|
-
expect(container.querySelector('header')).toBeTruthy()
|
|
74
78
|
expect(screen.getByText('Border test')).toBeInTheDocument()
|
|
79
|
+
expect(screen.getByText('1')).toBeInTheDocument()
|
|
80
|
+
expect(screen.getByText('X')).toBeInTheDocument()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('renders icon chip when theme omits primaryRgb (shadow fallback)', () => {
|
|
84
|
+
const colorsRest = { ...chronogroveTheme.colors }
|
|
85
|
+
delete colorsRest.primaryRgb
|
|
86
|
+
const themeNoPrimaryRgb = { ...chronogroveTheme, colors: colorsRest }
|
|
87
|
+
render(
|
|
88
|
+
<ThemeUIProvider theme={themeNoPrimaryRgb}>
|
|
89
|
+
<WidgetHeader icon={mockIcon}>Chip</WidgetHeader>
|
|
90
|
+
</ThemeUIProvider>
|
|
91
|
+
)
|
|
92
|
+
expect(screen.getByText('Chip')).toBeInTheDocument()
|
|
93
|
+
expect(screen.getByTestId('fa-icon')).toBeInTheDocument()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('renders metric chip border when theme omits colors.text (fallback)', () => {
|
|
97
|
+
const colorsRest = { ...chronogroveTheme.colors }
|
|
98
|
+
delete colorsRest.text
|
|
99
|
+
const themeNoText = { ...chronogroveTheme, colors: colorsRest }
|
|
100
|
+
render(
|
|
101
|
+
<ThemeUIProvider theme={themeNoText}>
|
|
102
|
+
<WidgetHeader icon={mockIcon} metrics={[{ displayName: 'Y', id: 'y', value: 2 }]}>
|
|
103
|
+
T
|
|
104
|
+
</WidgetHeader>
|
|
105
|
+
</ThemeUIProvider>
|
|
106
|
+
)
|
|
107
|
+
expect(screen.getByText('2')).toBeInTheDocument()
|
|
108
|
+
expect(screen.getByText('Y')).toBeInTheDocument()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('handles metrics array with undefined entry (default destructuring)', () => {
|
|
112
|
+
render(
|
|
113
|
+
<ThemeUIProvider theme={chronogroveTheme}>
|
|
114
|
+
<WidgetHeader icon={mockIcon} metrics={[undefined]}>
|
|
115
|
+
T
|
|
116
|
+
</WidgetHeader>
|
|
117
|
+
</ThemeUIProvider>
|
|
118
|
+
)
|
|
119
|
+
expect(screen.getByText('—')).toBeInTheDocument()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('uses metricsToShow empty array when metrics is not an array', () => {
|
|
123
|
+
render(
|
|
124
|
+
<ThemeUIProvider theme={chronogroveTheme}>
|
|
125
|
+
<WidgetHeader icon={mockIcon} metrics={null}>
|
|
126
|
+
No metrics prop array
|
|
127
|
+
</WidgetHeader>
|
|
128
|
+
</ThemeUIProvider>
|
|
129
|
+
)
|
|
130
|
+
expect(screen.getByText('No metrics prop array')).toBeInTheDocument()
|
|
75
131
|
})
|
|
76
132
|
})
|
package/src/widget-section.js
CHANGED
|
@@ -13,7 +13,7 @@ const sectionSx = {
|
|
|
13
13
|
/**
|
|
14
14
|
* Wraps a dashboard widget: vertical spacing and optional fatal-error overlay.
|
|
15
15
|
*/
|
|
16
|
-
const WidgetSection = ({ children, hasFatalError, id, styleOverrides = {}, ...props }) => {
|
|
16
|
+
const WidgetSection = ({ children, hasFatalError, id, styleOverrides = {}, tabIndex, ...props }) => {
|
|
17
17
|
const { colorMode } = useThemeUI()
|
|
18
18
|
const darkMode = isDarkMode(colorMode)
|
|
19
19
|
|
|
@@ -30,6 +30,7 @@ const WidgetSection = ({ children, hasFatalError, id, styleOverrides = {}, ...pr
|
|
|
30
30
|
: {})
|
|
31
31
|
}}
|
|
32
32
|
{...(id ? { id } : {})}
|
|
33
|
+
tabIndex={id ? (tabIndex ?? -1) : tabIndex}
|
|
33
34
|
{...props}
|
|
34
35
|
>
|
|
35
36
|
{hasFatalError && (
|
|
@@ -36,6 +36,38 @@ describe('WidgetSection', () => {
|
|
|
36
36
|
expect(document.getElementById('w1')).toBeTruthy()
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
+
it('sets tabIndex -1 when id is provided so the section can receive programmatic focus', () => {
|
|
40
|
+
useThemeUI.mockReturnValue({ colorMode: 'default' })
|
|
41
|
+
render(
|
|
42
|
+
<ThemeUIProvider theme={{}}>
|
|
43
|
+
<WidgetSection id='w1'>x</WidgetSection>
|
|
44
|
+
</ThemeUIProvider>
|
|
45
|
+
)
|
|
46
|
+
expect(document.getElementById('w1')).toHaveAttribute('tabindex', '-1')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('respects an explicit tabIndex when id is provided', () => {
|
|
50
|
+
useThemeUI.mockReturnValue({ colorMode: 'default' })
|
|
51
|
+
render(
|
|
52
|
+
<ThemeUIProvider theme={{}}>
|
|
53
|
+
<WidgetSection id='w1' tabIndex={0}>
|
|
54
|
+
x
|
|
55
|
+
</WidgetSection>
|
|
56
|
+
</ThemeUIProvider>
|
|
57
|
+
)
|
|
58
|
+
expect(document.getElementById('w1')).toHaveAttribute('tabindex', '0')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('does not set tabIndex when id is omitted', () => {
|
|
62
|
+
useThemeUI.mockReturnValue({ colorMode: 'default' })
|
|
63
|
+
render(
|
|
64
|
+
<ThemeUIProvider theme={{}}>
|
|
65
|
+
<WidgetSection>plain</WidgetSection>
|
|
66
|
+
</ThemeUIProvider>
|
|
67
|
+
)
|
|
68
|
+
expect(screen.getByText('plain').closest('section')).not.toHaveAttribute('tabindex')
|
|
69
|
+
})
|
|
70
|
+
|
|
39
71
|
it('shows fatal error overlay', () => {
|
|
40
72
|
useThemeUI.mockReturnValue({ colorMode: 'dark' })
|
|
41
73
|
render(
|