@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.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +19 -0
  3. package/dist/esm/index.js +119 -91
  4. package/dist/esm/index.js.map +1 -1
  5. package/dist/index.d.mts +110 -11
  6. package/dist/index.d.ts +110 -11
  7. package/dist/index.js +123 -93
  8. package/dist/index.js.map +1 -1
  9. package/dist/metric-highlight.css +17 -27
  10. package/dist/narrative-divider.css +27 -0
  11. package/dist/narrative-headline.css +7 -0
  12. package/dist/narrative-summary.css +126 -0
  13. package/package.json +7 -3
  14. package/src/EyebrowToken/EyebrowToken.tsx +1 -5
  15. package/src/MetricHighlight/MetricHighlight.stories.tsx +37 -14
  16. package/src/MetricHighlight/MetricHighlight.tsx +19 -99
  17. package/src/MetricHighlight/MetricHighlightTypes.ts +6 -9
  18. package/src/MetricHighlight/__tests__/MetricHighlight.test.tsx +13 -48
  19. package/src/NarrativeDivider/NarrativeDivider.stories.tsx +33 -0
  20. package/src/NarrativeDivider/NarrativeDivider.tsx +30 -0
  21. package/src/NarrativeDivider/NarrativeDividerTypes.ts +4 -0
  22. package/src/NarrativeDivider/__tests__/NarrativeDivider.test.tsx +35 -0
  23. package/src/NarrativeDivider/index.ts +2 -0
  24. package/src/NarrativeHeadline/NarrativeHeadline.tsx +21 -12
  25. package/src/NarrativeHeadline/NarrativeHeadlineRoll.tsx +4 -1
  26. package/src/NarrativeHeadline/NarrativeHeadlineTypes.ts +9 -0
  27. package/src/NarrativeHeadline/__tests__/NarrativeHeadline.test.tsx +2 -7
  28. package/src/NarrativeHeadline/index.ts +1 -0
  29. package/src/NarrativeSummary/NarrativeSummary.stories.tsx +79 -0
  30. package/src/NarrativeSummary/NarrativeSummary.tsx +98 -0
  31. package/src/NarrativeSummary/NarrativeSummaryTypes.ts +59 -0
  32. package/src/NarrativeSummary/__tests__/NarrativeSummary.test.tsx +99 -0
  33. package/src/NarrativeSummary/index.ts +5 -0
  34. package/src/Playground/Playground.stories.tsx +86 -36
  35. package/src/PullQuote/PullQuote.stories.tsx +3 -1
  36. package/src/PullQuote/PullQuoteTypes.ts +1 -2
  37. package/src/index.ts +11 -0
  38. package/src/metric-highlight.css +17 -27
  39. package/src/narrative-divider.css +27 -0
  40. package/src/narrative-headline.css +7 -0
  41. package/src/narrative-summary.css +126 -0
@@ -131,47 +131,37 @@
131
131
  stroke-width: 1.5;
132
132
  }
133
133
 
134
- /* Sparkline a single normalized polyline stretched to fit. The non-scaling
135
- stroke keeps a crisp 2px line at any aspect ratio. */
136
- .seeds-metric-highlight-spark {
137
- display: block;
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
- /* Matches the design's Data Viz / Categorical 01 line color (#0b968f). */
140
- stroke: var(--color-teal-700);
141
- }
142
-
143
- /* Downward trend: the red analog at the same scale step (#db3e3e). */
144
- .seeds-metric-highlight-spark-negative {
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
- /* Horizontal: a compact fixed-width chart beside the metric, matched to the
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
- align-self: stretch;
159
- min-height: 40px;
151
+ height: 48px;
160
152
  }
161
153
 
162
- .seeds-metric-highlight-small:not(.seeds-metric-highlight-vertical) .seeds-metric-highlight-spark {
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
- /* Vertical: a full-width chart stacked beneath the metric. */
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-spark {
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.2.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 without the sparkline. */
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
- chart: false,
79
+ sparkline: undefined,
58
80
  },
59
81
  };
60
82
 
61
83
  /**
62
- * Negative trend — the badge swaps to the red treatment and the default
63
- * sparkline slopes downward in red.
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
- /** Consumer-supplied data overrides the default series. */
74
- export const CustomData: Story = {
96
+ /** Icon-only trend badge (no number). */
97
+ export const TrendIconOnly: Story = {
75
98
  args: {
76
99
  className: "w-80",
77
- data: [4, 9, 6, 12, 8, 15, 11, 18, 14, 22],
100
+ trend: { direction: "up", "aria-label": "trending up" },
78
101
  },
79
102
  };
80
103
 
81
- /** Icon-only trend badge (no number). */
82
- export const TrendIconOnly: Story = {
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
- trend: { direction: "up", "aria-label": "trending up" },
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 = ({ direction = "up", value, ...rest }: TypeMetricHighlightTrend) => (
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 sparkline.
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<HTMLDivElement, TypeMetricHighlightProps>(
64
+ const MetricHighlight = React.forwardRef<
65
+ HTMLDivElement,
66
+ TypeMetricHighlightProps
67
+ >(
144
68
  (
145
69
  {
146
70
  label,
147
71
  value,
148
72
  trend,
149
- data,
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
- {showChart && <Sparkline data={resolvedData} negative={isDown} />}
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
- * Sparkline data points. When omitted, a sensible default series is used
32
- * (sloped to match `trend`). Pass an explicit array to override, or an empty
33
- * array to opt out of the chart.
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
- data?: number[];
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(container.querySelector(".seeds-metric-highlight-trend")).toBeInTheDocument();
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 when data is provided", () => {
67
+ it("renders the sparkline slot node in the chart area", () => {
94
68
  const { container } = render(
95
- <MetricHighlight label="Engagement" value="123" data={DATA} />
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-spark")
78
+ container.querySelector(".seeds-metric-highlight-chart")
100
79
  ).toBeInTheDocument();
101
80
  });
102
81
 
103
- it("renders a default sparkline when no data is provided", () => {
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-spark")
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