@chronogrove/ui 0.83.1 → 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 +1 -1
- package/package.json +1 -1
- package/src/__snapshots__/theme.spec.js.snap +10 -10
- package/src/__snapshots__/widget-header.spec.js.snap +21 -16
- package/src/color-utils.js +27 -2
- package/src/color-utils.spec.js +34 -1
- 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
|
|
package/package.json
CHANGED
|
@@ -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>
|
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
|
})
|
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(
|