@sproutsocial/seeds-react-narrative-kit 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +19 -0
- package/dist/esm/index.js +119 -91
- package/dist/esm/index.js.map +1 -1
- package/dist/index.d.mts +110 -11
- package/dist/index.d.ts +110 -11
- package/dist/index.js +123 -93
- package/dist/index.js.map +1 -1
- package/dist/metric-highlight.css +17 -27
- package/dist/narrative-divider.css +27 -0
- package/dist/narrative-headline.css +7 -0
- package/dist/narrative-summary.css +126 -0
- package/package.json +7 -3
- package/src/EyebrowToken/EyebrowToken.tsx +1 -5
- package/src/MetricHighlight/MetricHighlight.stories.tsx +37 -14
- package/src/MetricHighlight/MetricHighlight.tsx +19 -99
- package/src/MetricHighlight/MetricHighlightTypes.ts +6 -9
- package/src/MetricHighlight/__tests__/MetricHighlight.test.tsx +13 -48
- package/src/NarrativeDivider/NarrativeDivider.stories.tsx +33 -0
- package/src/NarrativeDivider/NarrativeDivider.tsx +30 -0
- package/src/NarrativeDivider/NarrativeDividerTypes.ts +4 -0
- package/src/NarrativeDivider/__tests__/NarrativeDivider.test.tsx +35 -0
- package/src/NarrativeDivider/index.ts +2 -0
- package/src/NarrativeHeadline/NarrativeHeadline.tsx +21 -12
- package/src/NarrativeHeadline/NarrativeHeadlineRoll.tsx +4 -1
- package/src/NarrativeHeadline/NarrativeHeadlineTypes.ts +9 -0
- package/src/NarrativeHeadline/__tests__/NarrativeHeadline.test.tsx +2 -7
- package/src/NarrativeHeadline/index.ts +1 -0
- package/src/NarrativeSummary/NarrativeSummary.stories.tsx +79 -0
- package/src/NarrativeSummary/NarrativeSummary.tsx +98 -0
- package/src/NarrativeSummary/NarrativeSummaryTypes.ts +59 -0
- package/src/NarrativeSummary/__tests__/NarrativeSummary.test.tsx +99 -0
- package/src/NarrativeSummary/index.ts +5 -0
- package/src/Playground/Playground.stories.tsx +86 -36
- package/src/PullQuote/PullQuote.stories.tsx +3 -1
- package/src/PullQuote/PullQuoteTypes.ts +1 -2
- package/src/index.ts +11 -0
- package/src/metric-highlight.css +17 -27
- package/src/narrative-divider.css +27 -0
- package/src/narrative-headline.css +7 -0
- package/src/narrative-summary.css +126 -0
|
@@ -131,47 +131,37 @@
|
|
|
131
131
|
stroke-width: 1.5;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
/*
|
|
135
|
-
stroke
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
/* Slot for an injected chart (e.g. a data-viz SparklineChart). Layout only — no
|
|
135
|
+
stroke/background styling, so the injected chart controls its own look. Heights
|
|
136
|
+
are explicit (never percentage/stretch) so a Highcharts chart always measures a
|
|
137
|
+
concrete container instead of falling back to its 400px default. */
|
|
138
|
+
.seeds-metric-highlight-chart {
|
|
139
|
+
box-sizing: border-box;
|
|
138
140
|
flex: 0 0 auto;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
stroke: var(--color-red-700);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
.seeds-metric-highlight-spark path {
|
|
149
|
-
stroke: inherit;
|
|
150
|
-
stroke-width: 2;
|
|
151
|
-
vector-effect: non-scaling-stroke;
|
|
141
|
+
align-self: center;
|
|
142
|
+
/* Center the injected chart as a flex item — an inline/inline-block child
|
|
143
|
+
(e.g. the data-viz SparklineChart) otherwise sits on the text baseline with
|
|
144
|
+
descender space below it, reading as vertically off-center in the slot. */
|
|
145
|
+
display: flex;
|
|
146
|
+
align-items: center;
|
|
152
147
|
}
|
|
153
148
|
|
|
154
|
-
|
|
155
|
-
metric block's height. */
|
|
156
|
-
.seeds-metric-highlight:not(.seeds-metric-highlight-vertical) .seeds-metric-highlight-spark {
|
|
149
|
+
.seeds-metric-highlight:not(.seeds-metric-highlight-vertical) .seeds-metric-highlight-chart {
|
|
157
150
|
width: 104px;
|
|
158
|
-
|
|
159
|
-
min-height: 40px;
|
|
151
|
+
height: 48px;
|
|
160
152
|
}
|
|
161
153
|
|
|
162
|
-
.seeds-metric-highlight-small:not(.seeds-metric-highlight-vertical) .seeds-metric-highlight-
|
|
154
|
+
.seeds-metric-highlight-small:not(.seeds-metric-highlight-vertical) .seeds-metric-highlight-chart {
|
|
163
155
|
width: 88px;
|
|
164
156
|
height: 40px;
|
|
165
|
-
align-self: center;
|
|
166
157
|
}
|
|
167
158
|
|
|
168
|
-
|
|
169
|
-
.seeds-metric-highlight-vertical .seeds-metric-highlight-spark {
|
|
159
|
+
.seeds-metric-highlight-vertical .seeds-metric-highlight-chart {
|
|
170
160
|
width: 100%;
|
|
171
161
|
height: 96px;
|
|
172
162
|
}
|
|
173
163
|
|
|
174
|
-
.seeds-metric-highlight-vertical.seeds-metric-highlight-small .seeds-metric-highlight-
|
|
164
|
+
.seeds-metric-highlight-vertical.seeds-metric-highlight-small .seeds-metric-highlight-chart {
|
|
175
165
|
height: 56px;
|
|
176
166
|
}
|
|
177
167
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seeds NarrativeDivider component classes.
|
|
3
|
+
* Use these instead of writing out individual Tailwind utility classes.
|
|
4
|
+
*
|
|
5
|
+
* Requires @sproutsocial/seeds-react-theme/dist/theme-all.css imported for
|
|
6
|
+
* CSS variable definitions and dark mode support.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* <hr class="seeds-narrative-divider" />
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
@layer components {
|
|
13
|
+
|
|
14
|
+
/* A full-width, 1px horizontal rule. The <hr> ships with user-agent margins
|
|
15
|
+
and a beveled border, so reset those and draw the line with a single
|
|
16
|
+
border-top using the container-border-base token (#dee1e1), which also
|
|
17
|
+
tracks dark mode. */
|
|
18
|
+
.seeds-narrative-divider {
|
|
19
|
+
box-sizing: border-box;
|
|
20
|
+
width: 100%;
|
|
21
|
+
height: 0;
|
|
22
|
+
margin: 0;
|
|
23
|
+
border: 0;
|
|
24
|
+
border-top: 1px solid var(--color-container-border-base); /* #dee1e1 */
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
}
|
|
@@ -27,6 +27,13 @@
|
|
|
27
27
|
color: var(--color-text-headline);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/* Compact size for denser layouts (e.g. NarrativeSummary). Weight, letter
|
|
31
|
+
spacing, and color are inherited from the base class. */
|
|
32
|
+
.seeds-narrative-headline-small {
|
|
33
|
+
font-size: var(--font-size-600); /* 24px */
|
|
34
|
+
line-height: var(--line-height-600); /* 32px */
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
/* Static gradient accent for a span of headline text ("highlighted word").
|
|
31
38
|
Paints the AI brand gradient onto the glyphs via background-clip. */
|
|
32
39
|
.seeds-narrative-headline-highlight {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seeds NarrativeSummary component classes.
|
|
3
|
+
* Use these instead of writing out individual Tailwind utility classes.
|
|
4
|
+
*
|
|
5
|
+
* Requires @sproutsocial/seeds-react-theme/dist/theme-all.css imported for
|
|
6
|
+
* CSS variable definitions and dark mode support. Also relies on
|
|
7
|
+
* narrative-headline.css (headline) and eyebrow-token.css (eyebrow).
|
|
8
|
+
*
|
|
9
|
+
* The layout is a CSS grid driven by a container query on the root, so the
|
|
10
|
+
* columns collapse based on the width the component is given, not the viewport.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* <div class="seeds-narrative-summary">
|
|
14
|
+
* <div class="seeds-narrative-summary-grid">…</div>
|
|
15
|
+
* </div>
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
@layer components {
|
|
19
|
+
|
|
20
|
+
/* Establishes the query container. The grid inside reacts to this element's
|
|
21
|
+
inline size. */
|
|
22
|
+
.seeds-narrative-summary {
|
|
23
|
+
container-type: inline-size;
|
|
24
|
+
box-sizing: border-box;
|
|
25
|
+
font-family: var(--font-family);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* Stacked single column by default (narrow). The container query below expands
|
|
29
|
+
it once there is room. */
|
|
30
|
+
.seeds-narrative-summary-grid {
|
|
31
|
+
display: grid;
|
|
32
|
+
grid-template-columns: 1fr;
|
|
33
|
+
gap: var(--space-450); /* 24px */
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* The lead column: eyebrow, headline, optional action. align-items:flex-start
|
|
37
|
+
keeps the eyebrow pill and the action sized to their content — without it the
|
|
38
|
+
column's default stretch would blow the eyebrow out to full width. The
|
|
39
|
+
headline still fills and wraps because its text is wider than the track. */
|
|
40
|
+
.seeds-narrative-summary-lead {
|
|
41
|
+
display: flex;
|
|
42
|
+
flex-direction: column;
|
|
43
|
+
align-items: flex-start;
|
|
44
|
+
gap: var(--space-300); /* 8px */
|
|
45
|
+
min-width: 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* The headline area grows so the action anchors toward the bottom of the lead
|
|
49
|
+
column when the neighboring columns are taller. align-self:stretch fills the
|
|
50
|
+
track width (the lead column pins other items to flex-start) so the headline
|
|
51
|
+
wraps at the column edge regardless of its length. */
|
|
52
|
+
.seeds-narrative-summary-headline {
|
|
53
|
+
flex: 1 1 auto;
|
|
54
|
+
align-self: stretch;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* In three-column mode the content wrapper is transparent to the grid, so its
|
|
58
|
+
two sections become grid items in their own columns. In two-column mode the
|
|
59
|
+
container query below turns it into a real, stacking grid item. */
|
|
60
|
+
.seeds-narrative-summary-content {
|
|
61
|
+
display: contents;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.seeds-narrative-summary-section {
|
|
65
|
+
display: flex;
|
|
66
|
+
flex-direction: column;
|
|
67
|
+
gap: var(--space-300); /* 8px */
|
|
68
|
+
min-width: 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* Section label. 14px / 24px has no exact type token (the scale skips from
|
|
72
|
+
13px to 16px), so the design value is kept verbatim — same rationale as
|
|
73
|
+
eyebrow-token.css. All other values use tokens. */
|
|
74
|
+
.seeds-narrative-summary-label {
|
|
75
|
+
margin: 0;
|
|
76
|
+
font-size: 14px;
|
|
77
|
+
line-height: 24px;
|
|
78
|
+
font-weight: var(--font-weight-bold);
|
|
79
|
+
color: var(--color-text-headline);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.seeds-narrative-summary-text {
|
|
83
|
+
margin: 0;
|
|
84
|
+
font-size: 14px; /* off-scale, see label above */
|
|
85
|
+
line-height: 24px;
|
|
86
|
+
font-weight: var(--font-weight-normal);
|
|
87
|
+
color: var(--color-text-body);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.seeds-narrative-summary-list {
|
|
91
|
+
margin: 0;
|
|
92
|
+
padding-inline-start: 21px;
|
|
93
|
+
list-style: disc;
|
|
94
|
+
font-size: 14px; /* off-scale, see label above */
|
|
95
|
+
line-height: 24px;
|
|
96
|
+
font-weight: var(--font-weight-normal);
|
|
97
|
+
color: var(--color-text-body);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.seeds-narrative-summary-list li {
|
|
101
|
+
margin: 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* Expand to the multi-column layout once the component is wide enough. The
|
|
105
|
+
threshold is on the component's own width (container query), so a narrow card
|
|
106
|
+
on a wide screen still stacks. 640px = Tailwind/Seeds sm breakpoint. */
|
|
107
|
+
@container (min-width: 640px) {
|
|
108
|
+
.seeds-narrative-summary-grid {
|
|
109
|
+
grid-template-columns: repeat(3, 1fr);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* 1/3 lead + 2/3 content. */
|
|
113
|
+
.seeds-narrative-summary-two-column {
|
|
114
|
+
grid-template-columns: 1fr 2fr;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* The content wrapper becomes a real grid item that stacks its two sections. */
|
|
118
|
+
.seeds-narrative-summary-two-column .seeds-narrative-summary-content {
|
|
119
|
+
display: flex;
|
|
120
|
+
flex-direction: column;
|
|
121
|
+
gap: var(--space-450); /* 24px */
|
|
122
|
+
min-width: 0;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sproutsocial/seeds-react-narrative-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Seeds React Narrative Kit — composable narrative primitives for executive-brief style pages",
|
|
5
5
|
"author": "Sprout Social, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
"./dist/narrative-headline.css": "./dist/narrative-headline.css",
|
|
18
18
|
"./dist/pull-quote.css": "./dist/pull-quote.css",
|
|
19
19
|
"./dist/metric-highlight.css": "./dist/metric-highlight.css",
|
|
20
|
-
"./dist/eyebrow-token.css": "./dist/eyebrow-token.css"
|
|
20
|
+
"./dist/eyebrow-token.css": "./dist/eyebrow-token.css",
|
|
21
|
+
"./dist/narrative-summary.css": "./dist/narrative-summary.css",
|
|
22
|
+
"./dist/narrative-divider.css": "./dist/narrative-divider.css"
|
|
21
23
|
},
|
|
22
24
|
"scripts": {
|
|
23
|
-
"build": "tsup --dts && cp src/narrative-container.css dist/narrative-container.css && cp src/narrative-headline.css dist/narrative-headline.css && cp src/pull-quote.css dist/pull-quote.css && cp src/metric-highlight.css dist/metric-highlight.css && cp src/eyebrow-token.css dist/eyebrow-token.css",
|
|
25
|
+
"build": "tsup --dts && cp src/narrative-container.css dist/narrative-container.css && cp src/narrative-headline.css dist/narrative-headline.css && cp src/pull-quote.css dist/pull-quote.css && cp src/metric-highlight.css dist/metric-highlight.css && cp src/eyebrow-token.css dist/eyebrow-token.css && cp src/narrative-summary.css dist/narrative-summary.css && cp src/narrative-divider.css dist/narrative-divider.css",
|
|
24
26
|
"build:debug": "tsup --dts --metafile",
|
|
25
27
|
"dev": "tsup --watch --dts",
|
|
26
28
|
"clean": "rm -rf .turbo dist",
|
|
@@ -34,6 +36,8 @@
|
|
|
34
36
|
},
|
|
35
37
|
"devDependencies": {
|
|
36
38
|
"@sproutsocial/eslint-config-seeds": "*",
|
|
39
|
+
"@sproutsocial/seeds-react-button": "*",
|
|
40
|
+
"@sproutsocial/seeds-react-data-viz": "*",
|
|
37
41
|
"@sproutsocial/seeds-react-grid": "^0.2.5",
|
|
38
42
|
"@sproutsocial/seeds-react-text": "*",
|
|
39
43
|
"@sproutsocial/seeds-react-testing-library": "*",
|
|
@@ -14,11 +14,7 @@ import type { TypeEyebrowTokenProps } from "./EyebrowTokenTypes";
|
|
|
14
14
|
*/
|
|
15
15
|
const EyebrowToken = React.forwardRef<HTMLSpanElement, TypeEyebrowTokenProps>(
|
|
16
16
|
({ children, className, ...props }, ref) => (
|
|
17
|
-
<span
|
|
18
|
-
ref={ref}
|
|
19
|
-
className={cn("seeds-eyebrow-token", className)}
|
|
20
|
-
{...props}
|
|
21
|
-
>
|
|
17
|
+
<span ref={ref} className={cn("seeds-eyebrow-token", className)} {...props}>
|
|
22
18
|
{children}
|
|
23
19
|
</span>
|
|
24
20
|
)
|
|
@@ -1,7 +1,31 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { SparklineChart } from "@sproutsocial/seeds-react-data-viz/sparkline";
|
|
3
4
|
import MetricHighlight from "./MetricHighlight";
|
|
4
5
|
import "../metric-highlight.css";
|
|
6
|
+
import "@sproutsocial/seeds-react-data-viz/sparkline.css";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* MetricHighlight has no built-in chart — the `sparkline` slot accepts any node.
|
|
10
|
+
* These stories inject the real `SparklineChart` from
|
|
11
|
+
* `@sproutsocial/seeds-react-data-viz`; narrative-kit takes no runtime
|
|
12
|
+
* dependency on data-viz (only these stories do, via a devDependency). The
|
|
13
|
+
* injected chart fills the slot with `className="h-full w-full"`.
|
|
14
|
+
*/
|
|
15
|
+
const RISING = [4, 6, 5, 8, 7, 10];
|
|
16
|
+
const FALLING = [18, 16, 17, 13, 14, 10];
|
|
17
|
+
|
|
18
|
+
const spark = (
|
|
19
|
+
data: number[],
|
|
20
|
+
variant: "line" | "bar" = "line"
|
|
21
|
+
): React.ReactNode => (
|
|
22
|
+
<SparklineChart
|
|
23
|
+
variant={variant}
|
|
24
|
+
description="Weekly trend"
|
|
25
|
+
data={data}
|
|
26
|
+
className="h-full w-full"
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
5
29
|
|
|
6
30
|
const meta = {
|
|
7
31
|
title: "Narrative Kit/MetricHighlight",
|
|
@@ -10,16 +34,14 @@ const meta = {
|
|
|
10
34
|
label: "Insight metric",
|
|
11
35
|
value: "123",
|
|
12
36
|
trend: { direction: "up", value: "240%", "aria-label": "up 240%" },
|
|
37
|
+
sparkline: spark(RISING),
|
|
13
38
|
},
|
|
14
39
|
} satisfies Meta<typeof MetricHighlight>;
|
|
15
40
|
|
|
16
41
|
export default meta;
|
|
17
42
|
type Story = StoryObj<typeof meta>;
|
|
18
43
|
|
|
19
|
-
/**
|
|
20
|
-
* Horizontal, default size, padded surface, numeric trend badge, and the
|
|
21
|
-
* default sparkline (no `data` passed).
|
|
22
|
-
*/
|
|
44
|
+
/** Horizontal, default size, padded surface, numeric trend badge, and a sparkline. */
|
|
23
45
|
export const Default: Story = {
|
|
24
46
|
args: {
|
|
25
47
|
className: "w-80",
|
|
@@ -50,38 +72,39 @@ export const WithoutContainer: Story = {
|
|
|
50
72
|
},
|
|
51
73
|
};
|
|
52
74
|
|
|
53
|
-
/** Metric
|
|
75
|
+
/** Metric on its own — omit the `sparkline` slot to drop the chart. */
|
|
54
76
|
export const WithoutChart: Story = {
|
|
55
77
|
args: {
|
|
56
78
|
className: "w-80",
|
|
57
|
-
|
|
79
|
+
sparkline: undefined,
|
|
58
80
|
},
|
|
59
81
|
};
|
|
60
82
|
|
|
61
83
|
/**
|
|
62
|
-
* Negative trend — the badge swaps to the red treatment and the
|
|
63
|
-
*
|
|
84
|
+
* Negative trend — the badge swaps to the red treatment and the sparkline (which
|
|
85
|
+
* auto-colors from its data) slopes downward in red.
|
|
64
86
|
*/
|
|
65
87
|
export const TrendDown: Story = {
|
|
66
88
|
args: {
|
|
67
89
|
className: "w-80",
|
|
68
90
|
value: "87",
|
|
69
91
|
trend: { direction: "down", value: "12%", "aria-label": "down 12%" },
|
|
92
|
+
sparkline: spark(FALLING),
|
|
70
93
|
},
|
|
71
94
|
};
|
|
72
95
|
|
|
73
|
-
/**
|
|
74
|
-
export const
|
|
96
|
+
/** Icon-only trend badge (no number). */
|
|
97
|
+
export const TrendIconOnly: Story = {
|
|
75
98
|
args: {
|
|
76
99
|
className: "w-80",
|
|
77
|
-
|
|
100
|
+
trend: { direction: "up", "aria-label": "trending up" },
|
|
78
101
|
},
|
|
79
102
|
};
|
|
80
103
|
|
|
81
|
-
/**
|
|
82
|
-
export const
|
|
104
|
+
/** The slot accepts the bar variant of the sparkline just as well. */
|
|
105
|
+
export const BarSparkline: Story = {
|
|
83
106
|
args: {
|
|
84
107
|
className: "w-80",
|
|
85
|
-
|
|
108
|
+
sparkline: spark(RISING, "bar"),
|
|
86
109
|
},
|
|
87
110
|
};
|
|
@@ -5,91 +5,6 @@ import type {
|
|
|
5
5
|
TypeMetricHighlightTrend,
|
|
6
6
|
} from "./MetricHighlightTypes";
|
|
7
7
|
|
|
8
|
-
/** Fixed viewBox the sparkline normalizes into; CSS stretches it to fit. */
|
|
9
|
-
const SPARK_VIEWBOX_WIDTH = 100;
|
|
10
|
-
const SPARK_VIEWBOX_HEIGHT = 32;
|
|
11
|
-
/** Inset so the stroke at the min/max points isn't clipped by the viewBox edge. */
|
|
12
|
-
const SPARK_PADDING = 2;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Default sparkline series used when the consumer doesn't pass `data` — a gently
|
|
16
|
-
* rising wave. It's reversed for a downward trend so the line slopes to match.
|
|
17
|
-
*/
|
|
18
|
-
const DEFAULT_SPARK = [6, 10, 8, 13, 11, 16];
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Build a smooth SVG path through `points` using a Catmull-Rom spline converted
|
|
22
|
-
* to cubic béziers. Each segment's control points are derived from its
|
|
23
|
-
* neighbors, so the curve passes through every data point with soft, rounded
|
|
24
|
-
* transitions (matching the design) rather than sharp polyline corners.
|
|
25
|
-
*/
|
|
26
|
-
const smoothPath = (points: { x: number; y: number }[]) => {
|
|
27
|
-
if (points.length < 2) {
|
|
28
|
-
const p = points[0];
|
|
29
|
-
return p ? `M ${p.x},${p.y}` : "";
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
let d = `M ${points[0]!.x.toFixed(2)},${points[0]!.y.toFixed(2)}`;
|
|
33
|
-
for (let i = 0; i < points.length - 1; i++) {
|
|
34
|
-
// The loop bound guarantees p1 and p2; p0/p3 clamp to the endpoints.
|
|
35
|
-
const p1 = points[i]!;
|
|
36
|
-
const p2 = points[i + 1]!;
|
|
37
|
-
const p0 = points[i - 1] ?? p1;
|
|
38
|
-
const p3 = points[i + 2] ?? p2;
|
|
39
|
-
// Catmull-Rom → bézier control points (tension 1/6).
|
|
40
|
-
const cp1x = p1.x + (p2.x - p0.x) / 6;
|
|
41
|
-
const cp1y = p1.y + (p2.y - p0.y) / 6;
|
|
42
|
-
const cp2x = p2.x - (p3.x - p1.x) / 6;
|
|
43
|
-
const cp2y = p2.y - (p3.y - p1.y) / 6;
|
|
44
|
-
d += ` C ${cp1x.toFixed(2)},${cp1y.toFixed(2)} ${cp2x.toFixed(
|
|
45
|
-
2
|
|
46
|
-
)},${cp2y.toFixed(2)} ${p2.x.toFixed(2)},${p2.y.toFixed(2)}`;
|
|
47
|
-
}
|
|
48
|
-
return d;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Sparkline — an inline SVG path normalized from `data` into a fixed viewBox and
|
|
53
|
-
* smoothed into a soft curve. `preserveAspectRatio="none"` plus a non-scaling
|
|
54
|
-
* stroke (set in CSS) lets a single path stretch to any width/height while
|
|
55
|
-
* keeping a crisp line.
|
|
56
|
-
*/
|
|
57
|
-
const Sparkline = ({
|
|
58
|
-
data,
|
|
59
|
-
negative,
|
|
60
|
-
}: {
|
|
61
|
-
data: number[];
|
|
62
|
-
negative?: boolean;
|
|
63
|
-
}) => {
|
|
64
|
-
const min = Math.min(...data);
|
|
65
|
-
const max = Math.max(...data);
|
|
66
|
-
const range = max - min || 1;
|
|
67
|
-
const usableHeight = SPARK_VIEWBOX_HEIGHT - SPARK_PADDING * 2;
|
|
68
|
-
|
|
69
|
-
const points = data.map((value, i) => ({
|
|
70
|
-
x:
|
|
71
|
-
data.length === 1
|
|
72
|
-
? SPARK_VIEWBOX_WIDTH / 2
|
|
73
|
-
: (i / (data.length - 1)) * SPARK_VIEWBOX_WIDTH,
|
|
74
|
-
// Invert y so larger values sit higher in the chart.
|
|
75
|
-
y: SPARK_PADDING + (1 - (value - min) / range) * usableHeight,
|
|
76
|
-
}));
|
|
77
|
-
|
|
78
|
-
return (
|
|
79
|
-
<svg
|
|
80
|
-
className={cn("seeds-metric-highlight-spark", {
|
|
81
|
-
"seeds-metric-highlight-spark-negative": Boolean(negative),
|
|
82
|
-
})}
|
|
83
|
-
viewBox={`0 0 ${SPARK_VIEWBOX_WIDTH} ${SPARK_VIEWBOX_HEIGHT}`}
|
|
84
|
-
preserveAspectRatio="none"
|
|
85
|
-
aria-hidden
|
|
86
|
-
focusable={false}
|
|
87
|
-
>
|
|
88
|
-
<path d={smoothPath(points)} fill="none" strokeLinecap="round" />
|
|
89
|
-
</svg>
|
|
90
|
-
);
|
|
91
|
-
};
|
|
92
|
-
|
|
93
8
|
/** Directional arrow glyph for the trend badge (decorative; `aria-hidden`). */
|
|
94
9
|
const TrendArrow = ({ direction }: { direction: "up" | "down" }) => (
|
|
95
10
|
<svg
|
|
@@ -116,7 +31,11 @@ const TrendArrow = ({ direction }: { direction: "up" | "down" }) => (
|
|
|
116
31
|
</svg>
|
|
117
32
|
);
|
|
118
33
|
|
|
119
|
-
const TrendBadge = ({
|
|
34
|
+
const TrendBadge = ({
|
|
35
|
+
direction = "up",
|
|
36
|
+
value,
|
|
37
|
+
...rest
|
|
38
|
+
}: TypeMetricHighlightTrend) => (
|
|
120
39
|
<span
|
|
121
40
|
className={cn("seeds-metric-highlight-trend", {
|
|
122
41
|
"seeds-metric-highlight-trend-down": direction === "down",
|
|
@@ -133,21 +52,25 @@ const TrendBadge = ({ direction = "up", value, ...rest }: TypeMetricHighlightTre
|
|
|
133
52
|
/**
|
|
134
53
|
* MetricHighlight — a narrative primitive that spotlights a single metric: a
|
|
135
54
|
* label, a large monospace value, an optional trend badge (a directional arrow
|
|
136
|
-
* plus an optional number such as "240%"), and an optional
|
|
55
|
+
* plus an optional number such as "240%"), and an optional chart supplied via
|
|
56
|
+
* the `sparkline` slot (e.g. a `<SparklineChart />` from
|
|
57
|
+
* `@sproutsocial/seeds-react-data-viz/sparkline`).
|
|
137
58
|
*
|
|
138
59
|
* Styled via `metric-highlight.css` (Seeds CSS custom properties); consumers
|
|
139
60
|
* import the classes through `@sproutsocial/racine/css/components`. Layout flexes
|
|
140
61
|
* across `appearance` (horizontal/vertical) and `size` (default/small); surface
|
|
141
62
|
* overrides are done with standard `className` / `style`.
|
|
142
63
|
*/
|
|
143
|
-
const MetricHighlight = React.forwardRef<
|
|
64
|
+
const MetricHighlight = React.forwardRef<
|
|
65
|
+
HTMLDivElement,
|
|
66
|
+
TypeMetricHighlightProps
|
|
67
|
+
>(
|
|
144
68
|
(
|
|
145
69
|
{
|
|
146
70
|
label,
|
|
147
71
|
value,
|
|
148
72
|
trend,
|
|
149
|
-
|
|
150
|
-
chart = true,
|
|
73
|
+
sparkline,
|
|
151
74
|
appearance = "horizontal",
|
|
152
75
|
size = "default",
|
|
153
76
|
container = true,
|
|
@@ -156,14 +79,6 @@ const MetricHighlight = React.forwardRef<HTMLDivElement, TypeMetricHighlightProp
|
|
|
156
79
|
},
|
|
157
80
|
ref
|
|
158
81
|
) => {
|
|
159
|
-
const isDown = trend?.direction === "down";
|
|
160
|
-
// Fall back to a sensible default series, sloped to match the trend, so the
|
|
161
|
-
// chart renders without the consumer wiring up data. An explicit `data`
|
|
162
|
-
// (including an empty array to opt out) always wins.
|
|
163
|
-
const resolvedData =
|
|
164
|
-
data ?? (isDown ? [...DEFAULT_SPARK].reverse() : DEFAULT_SPARK);
|
|
165
|
-
const showChart = chart && resolvedData.length > 0;
|
|
166
|
-
|
|
167
82
|
return (
|
|
168
83
|
<div
|
|
169
84
|
ref={ref}
|
|
@@ -185,7 +100,12 @@ const MetricHighlight = React.forwardRef<HTMLDivElement, TypeMetricHighlightProp
|
|
|
185
100
|
{trend != null && <TrendBadge {...trend} />}
|
|
186
101
|
</div>
|
|
187
102
|
</div>
|
|
188
|
-
{
|
|
103
|
+
{sparkline != null && (
|
|
104
|
+
// Layout-only slot (bounded dimensions per horizontal/vertical/small)
|
|
105
|
+
// for a consumer-supplied chart. It controls its own look; the slot
|
|
106
|
+
// only sizes and centers it.
|
|
107
|
+
<div className="seeds-metric-highlight-chart">{sparkline}</div>
|
|
108
|
+
)}
|
|
189
109
|
</div>
|
|
190
110
|
);
|
|
191
111
|
}
|
|
@@ -28,16 +28,13 @@ export interface TypeMetricHighlightProps
|
|
|
28
28
|
/** Optional trend badge — a directional arrow plus an optional value. */
|
|
29
29
|
trend?: TypeMetricHighlightTrend;
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* Chart slot. When provided, this node renders in the chart area beside/below
|
|
32
|
+
* the metric — e.g. a `<SparklineChart />` from
|
|
33
|
+
* `@sproutsocial/seeds-react-data-viz/sparkline`. Omit it to render the metric
|
|
34
|
+
* on its own. The injected node controls its own look; the slot only sizes and
|
|
35
|
+
* centers it (pass `className="h-full w-full"` to fill).
|
|
34
36
|
*/
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Render the sparkline.
|
|
38
|
-
* @default true
|
|
39
|
-
*/
|
|
40
|
-
chart?: boolean;
|
|
37
|
+
sparkline?: React.ReactNode;
|
|
41
38
|
/**
|
|
42
39
|
* Layout direction.
|
|
43
40
|
* - `horizontal` — metric block beside the sparkline.
|
|
@@ -2,8 +2,6 @@ import React from "react";
|
|
|
2
2
|
import { render, screen } from "@sproutsocial/seeds-react-testing-library";
|
|
3
3
|
import MetricHighlight from "../MetricHighlight";
|
|
4
4
|
|
|
5
|
-
const DATA = [1, 4, 2, 6, 3];
|
|
6
|
-
|
|
7
5
|
describe("MetricHighlight", () => {
|
|
8
6
|
it("renders the label and value", () => {
|
|
9
7
|
render(<MetricHighlight label="Engagement" value="123" />);
|
|
@@ -33,7 +31,9 @@ describe("MetricHighlight", () => {
|
|
|
33
31
|
/>
|
|
34
32
|
);
|
|
35
33
|
|
|
36
|
-
expect(
|
|
34
|
+
expect(
|
|
35
|
+
container.querySelector(".seeds-metric-highlight-trend")
|
|
36
|
+
).toBeInTheDocument();
|
|
37
37
|
expect(
|
|
38
38
|
container.querySelector(".seeds-metric-highlight-trend-value")
|
|
39
39
|
).not.toBeInTheDocument();
|
|
@@ -54,32 +54,6 @@ describe("MetricHighlight", () => {
|
|
|
54
54
|
).toBeInTheDocument();
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
it("marks the sparkline negative for a downward trend", () => {
|
|
58
|
-
const { container, rerender } = render(
|
|
59
|
-
<MetricHighlight
|
|
60
|
-
label="Engagement"
|
|
61
|
-
value="123"
|
|
62
|
-
data={DATA}
|
|
63
|
-
trend={{ direction: "down", value: "12%" }}
|
|
64
|
-
/>
|
|
65
|
-
);
|
|
66
|
-
expect(
|
|
67
|
-
container.querySelector(".seeds-metric-highlight-spark-negative")
|
|
68
|
-
).toBeInTheDocument();
|
|
69
|
-
|
|
70
|
-
rerender(
|
|
71
|
-
<MetricHighlight
|
|
72
|
-
label="Engagement"
|
|
73
|
-
value="123"
|
|
74
|
-
data={DATA}
|
|
75
|
-
trend={{ direction: "up", value: "12%" }}
|
|
76
|
-
/>
|
|
77
|
-
);
|
|
78
|
-
expect(
|
|
79
|
-
container.querySelector(".seeds-metric-highlight-spark-negative")
|
|
80
|
-
).not.toBeInTheDocument();
|
|
81
|
-
});
|
|
82
|
-
|
|
83
57
|
it("omits the trend badge when no trend is provided", () => {
|
|
84
58
|
const { container } = render(
|
|
85
59
|
<MetricHighlight label="Engagement" value="123" />
|
|
@@ -90,37 +64,28 @@ describe("MetricHighlight", () => {
|
|
|
90
64
|
).not.toBeInTheDocument();
|
|
91
65
|
});
|
|
92
66
|
|
|
93
|
-
it("renders the sparkline
|
|
67
|
+
it("renders the sparkline slot node in the chart area", () => {
|
|
94
68
|
const { container } = render(
|
|
95
|
-
<MetricHighlight
|
|
69
|
+
<MetricHighlight
|
|
70
|
+
label="Engagement"
|
|
71
|
+
value="123"
|
|
72
|
+
sparkline={<div data-testid="custom-spark">chart</div>}
|
|
73
|
+
/>
|
|
96
74
|
);
|
|
97
75
|
|
|
76
|
+
expect(screen.getByTestId("custom-spark")).toBeInTheDocument();
|
|
98
77
|
expect(
|
|
99
|
-
container.querySelector(".seeds-metric-highlight-
|
|
78
|
+
container.querySelector(".seeds-metric-highlight-chart")
|
|
100
79
|
).toBeInTheDocument();
|
|
101
80
|
});
|
|
102
81
|
|
|
103
|
-
it("
|
|
82
|
+
it("omits the chart area when no sparkline slot is provided", () => {
|
|
104
83
|
const { container } = render(
|
|
105
84
|
<MetricHighlight label="Engagement" value="123" />
|
|
106
85
|
);
|
|
107
86
|
|
|
108
87
|
expect(
|
|
109
|
-
container.querySelector(".seeds-metric-highlight-
|
|
110
|
-
).toBeInTheDocument();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("omits the sparkline when chart is false or data is empty", () => {
|
|
114
|
-
const { container, rerender } = render(
|
|
115
|
-
<MetricHighlight label="Engagement" value="123" data={DATA} chart={false} />
|
|
116
|
-
);
|
|
117
|
-
expect(
|
|
118
|
-
container.querySelector(".seeds-metric-highlight-spark")
|
|
119
|
-
).not.toBeInTheDocument();
|
|
120
|
-
|
|
121
|
-
rerender(<MetricHighlight label="Engagement" value="123" data={[]} />);
|
|
122
|
-
expect(
|
|
123
|
-
container.querySelector(".seeds-metric-highlight-spark")
|
|
88
|
+
container.querySelector(".seeds-metric-highlight-chart")
|
|
124
89
|
).not.toBeInTheDocument();
|
|
125
90
|
});
|
|
126
91
|
|