@sproutsocial/seeds-react-narrative-kit 0.2.0 → 0.4.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 (65) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +46 -0
  3. package/dist/ai-container.css +39 -0
  4. package/dist/esm/index.js +217 -91
  5. package/dist/esm/index.js.map +1 -1
  6. package/dist/index.d.mts +232 -14
  7. package/dist/index.d.ts +232 -14
  8. package/dist/index.js +224 -93
  9. package/dist/index.js.map +1 -1
  10. package/dist/metric-highlight.css +17 -27
  11. package/dist/narrative-data-callout.css +103 -0
  12. package/dist/narrative-divider.css +27 -0
  13. package/dist/narrative-headline.css +7 -0
  14. package/dist/narrative-ordered-list.css +112 -0
  15. package/dist/narrative-summary.css +126 -0
  16. package/dist/pull-quote.css +4 -25
  17. package/package.json +12 -5
  18. package/src/AIContainer/AIContainer.stories.tsx +54 -0
  19. package/src/AIContainer/AIContainer.tsx +35 -0
  20. package/src/AIContainer/AIContainerTypes.ts +14 -0
  21. package/src/AIContainer/__tests__/AIContainer.test.tsx +61 -0
  22. package/src/AIContainer/index.ts +2 -0
  23. package/src/EyebrowToken/EyebrowToken.tsx +1 -5
  24. package/src/MetricHighlight/MetricHighlight.stories.tsx +37 -14
  25. package/src/MetricHighlight/MetricHighlight.tsx +19 -99
  26. package/src/MetricHighlight/MetricHighlightTypes.ts +6 -9
  27. package/src/MetricHighlight/__tests__/MetricHighlight.test.tsx +13 -48
  28. package/src/NarrativeDataCallout/NarrativeDataCallout.stories.tsx +71 -0
  29. package/src/NarrativeDataCallout/NarrativeDataCallout.tsx +50 -0
  30. package/src/NarrativeDataCallout/NarrativeDataCalloutTypes.ts +18 -0
  31. package/src/NarrativeDataCallout/__tests__/NarrativeDataCallout.test.tsx +69 -0
  32. package/src/NarrativeDataCallout/index.ts +2 -0
  33. package/src/NarrativeDivider/NarrativeDivider.stories.tsx +33 -0
  34. package/src/NarrativeDivider/NarrativeDivider.tsx +30 -0
  35. package/src/NarrativeDivider/NarrativeDividerTypes.ts +4 -0
  36. package/src/NarrativeDivider/__tests__/NarrativeDivider.test.tsx +35 -0
  37. package/src/NarrativeDivider/index.ts +2 -0
  38. package/src/NarrativeHeadline/NarrativeHeadline.tsx +21 -12
  39. package/src/NarrativeHeadline/NarrativeHeadlineRoll.tsx +4 -1
  40. package/src/NarrativeHeadline/NarrativeHeadlineTypes.ts +9 -0
  41. package/src/NarrativeHeadline/__tests__/NarrativeHeadline.test.tsx +2 -7
  42. package/src/NarrativeHeadline/index.ts +1 -0
  43. package/src/NarrativeOrderedList/NarrativeOrderedList.stories.tsx +69 -0
  44. package/src/NarrativeOrderedList/NarrativeOrderedList.tsx +74 -0
  45. package/src/NarrativeOrderedList/NarrativeOrderedListTypes.ts +39 -0
  46. package/src/NarrativeOrderedList/__tests__/NarrativeOrderedList.test.tsx +89 -0
  47. package/src/NarrativeOrderedList/index.ts +6 -0
  48. package/src/NarrativeSummary/NarrativeSummary.stories.tsx +79 -0
  49. package/src/NarrativeSummary/NarrativeSummary.tsx +98 -0
  50. package/src/NarrativeSummary/NarrativeSummaryTypes.ts +59 -0
  51. package/src/NarrativeSummary/__tests__/NarrativeSummary.test.tsx +99 -0
  52. package/src/NarrativeSummary/index.ts +5 -0
  53. package/src/Playground/Playground.stories.tsx +174 -36
  54. package/src/PullQuote/PullQuote.stories.tsx +4 -1
  55. package/src/PullQuote/PullQuote.tsx +6 -3
  56. package/src/PullQuote/PullQuoteTypes.ts +1 -2
  57. package/src/ai-container.css +39 -0
  58. package/src/index.ts +25 -0
  59. package/src/metric-highlight.css +17 -27
  60. package/src/narrative-data-callout.css +103 -0
  61. package/src/narrative-divider.css +27 -0
  62. package/src/narrative-headline.css +7 -0
  63. package/src/narrative-ordered-list.css +112 -0
  64. package/src/narrative-summary.css +126 -0
  65. package/src/pull-quote.css +4 -25
@@ -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
 
@@ -0,0 +1,71 @@
1
+ import React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { DonutChart } from "@sproutsocial/seeds-react-data-viz/donut";
4
+ import NarrativeDataCallout from "./NarrativeDataCallout";
5
+ import NarrativeContainer from "../NarrativeContainer/NarrativeContainer";
6
+ import "../narrative-data-callout.css";
7
+ import "../narrative-container.css";
8
+
9
+ /**
10
+ * NarrativeDataCallout has no built-in chart — the `visual` slot accepts any
11
+ * node. These stories inject the real v2 `DonutChart` from
12
+ * `@sproutsocial/seeds-react-data-viz/donut`; narrative-kit takes no runtime
13
+ * dependency on data-viz (only these stories do, via a devDependency). The
14
+ * injected chart fills the left third; the copy fills the right two-thirds.
15
+ */
16
+ const donut = (): React.ReactNode => (
17
+ <DonutChart
18
+ description="Completion rate: 80% complete"
19
+ data={[
20
+ { name: "Complete", value: 80 },
21
+ { name: "Remaining", value: 20 },
22
+ ]}
23
+ />
24
+ );
25
+
26
+ const BODY =
27
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec odio leo, " +
28
+ "gravida vitae sodales vitae, tempor eget ligula. Vestibulum nec enim eget " +
29
+ "mi placerat finibus. Donec tempor tincidunt elit.";
30
+
31
+ const meta = {
32
+ title: "Narrative Kit/NarrativeDataCallout",
33
+ component: NarrativeDataCallout,
34
+ args: {
35
+ visual: donut(),
36
+ children: BODY,
37
+ },
38
+ } satisfies Meta<typeof NarrativeDataCallout>;
39
+
40
+ export default meta;
41
+ type Story = StoryObj<typeof meta>;
42
+
43
+ /** Bare inner content — the visual on the left third, copy on the right. */
44
+ export const Default: Story = {
45
+ args: {
46
+ className: "w-full max-w-240",
47
+ },
48
+ };
49
+
50
+ /**
51
+ * Dropped into a `NarrativeContainer` to reproduce the gradient-accented card
52
+ * from the design. This is the intended composition — the callout itself carries
53
+ * no surface.
54
+ */
55
+ export const InContainer: Story = {
56
+ render: (args) => (
57
+ <NarrativeContainer className="w-full max-w-240">
58
+ <NarrativeDataCallout {...args} />
59
+ </NarrativeContainer>
60
+ ),
61
+ };
62
+
63
+ /**
64
+ * Narrow width — the container query collapses the row to a stacked column
65
+ * (visual on top, copy below). Resize the canvas to watch it reflow.
66
+ */
67
+ export const Stacked: Story = {
68
+ args: {
69
+ className: "w-80",
70
+ },
71
+ };
@@ -0,0 +1,50 @@
1
+ import * as React from "react";
2
+ import { cn } from "../_internal/cn";
3
+ import type { TypeNarrativeDataCalloutProps } from "./NarrativeDataCalloutTypes";
4
+
5
+ /**
6
+ * NarrativeDataCallout — a horizontally oriented callout that pairs an injected
7
+ * data-viz visual (left, ~1/3 width) with a block of body copy (right, ~2/3).
8
+ *
9
+ * The kit ships no chart of its own; the `visual` slot accepts any node — the
10
+ * stories drop in a v2 `DonutChart` from `@sproutsocial/seeds-react-data-viz`.
11
+ * narrative-kit takes no runtime dependency on data-viz (mirrors MetricHighlight's
12
+ * `sparkline` slot).
13
+ *
14
+ * Renders inner content only — it carries no surface, shadow, or accent of its
15
+ * own. Drop it into a `NarrativeContainer` (see the stories) to reproduce the
16
+ * card look.
17
+ *
18
+ * Layout is a flex row driven by a container query, so it collapses to a stacked
19
+ * column (visual on top, text below) based on the width the component is actually
20
+ * given, not the viewport.
21
+ *
22
+ * Styled via `narrative-data-callout.css` (Seeds CSS custom properties); consumers
23
+ * import the classes through `@sproutsocial/racine/css/components`. Overrides come
24
+ * via standard `className` / `style`.
25
+ */
26
+ const NarrativeDataCallout = React.forwardRef<
27
+ HTMLDivElement,
28
+ TypeNarrativeDataCalloutProps
29
+ >(({ visual, children, visualLabel, className, ...props }, ref) => (
30
+ <div
31
+ ref={ref}
32
+ className={cn("seeds-narrative-data-callout", className)}
33
+ {...props}
34
+ >
35
+ <div className="seeds-narrative-data-callout-row">
36
+ <div
37
+ className="seeds-narrative-data-callout-visual"
38
+ role={visualLabel != null ? "img" : undefined}
39
+ aria-label={visualLabel}
40
+ >
41
+ {visual}
42
+ </div>
43
+ <div className="seeds-narrative-data-callout-body">{children}</div>
44
+ </div>
45
+ </div>
46
+ ));
47
+
48
+ NarrativeDataCallout.displayName = "NarrativeDataCallout";
49
+
50
+ export default NarrativeDataCallout;
@@ -0,0 +1,18 @@
1
+ import type * as React from "react";
2
+
3
+ export interface TypeNarrativeDataCalloutProps
4
+ extends React.HTMLAttributes<HTMLDivElement> {
5
+ /**
6
+ * Data-viz component rendered in the left slot (occupies ~1/3 of the width).
7
+ * The kit ships no chart of its own — drop in any data-viz node, e.g. a v2
8
+ * `DonutChart` from `@sproutsocial/seeds-react-data-viz`.
9
+ */
10
+ visual: React.ReactNode;
11
+ /** Body copy / description occupying the right two-thirds. */
12
+ children: React.ReactNode;
13
+ /**
14
+ * Accessible label for the visual slot region. Omit when the injected visual
15
+ * already carries its own description/label.
16
+ */
17
+ visualLabel?: string;
18
+ }
@@ -0,0 +1,69 @@
1
+ import React from "react";
2
+ import { render, screen } from "@sproutsocial/seeds-react-testing-library";
3
+ import NarrativeDataCallout from "../NarrativeDataCallout";
4
+
5
+ describe("NarrativeDataCallout", () => {
6
+ it("renders the body copy", () => {
7
+ render(
8
+ <NarrativeDataCallout visual={<div>chart</div>}>
9
+ Engagement climbed this month.
10
+ </NarrativeDataCallout>
11
+ );
12
+
13
+ expect(
14
+ screen.getByText("Engagement climbed this month.")
15
+ ).toBeInTheDocument();
16
+ });
17
+
18
+ it("renders the injected visual node in the visual slot", () => {
19
+ const { container } = render(
20
+ <NarrativeDataCallout visual={<div data-testid="custom-viz">chart</div>}>
21
+ Body
22
+ </NarrativeDataCallout>
23
+ );
24
+
25
+ expect(screen.getByTestId("custom-viz")).toBeInTheDocument();
26
+ expect(
27
+ container.querySelector(".seeds-narrative-data-callout-visual")
28
+ ).toBeInTheDocument();
29
+ });
30
+
31
+ it("applies the root class and merges a custom className", () => {
32
+ const { container } = render(
33
+ <NarrativeDataCallout visual={<div>chart</div>} className="custom">
34
+ Body
35
+ </NarrativeDataCallout>
36
+ );
37
+
38
+ const root = container.querySelector(".seeds-narrative-data-callout");
39
+ expect(root).toBeInTheDocument();
40
+ expect(root).toHaveClass("custom");
41
+ });
42
+
43
+ it("labels the visual region when visualLabel is provided", () => {
44
+ render(
45
+ <NarrativeDataCallout
46
+ visual={<div>chart</div>}
47
+ visualLabel="Completion rate"
48
+ >
49
+ Body
50
+ </NarrativeDataCallout>
51
+ );
52
+
53
+ expect(
54
+ screen.getByRole("img", { name: "Completion rate" })
55
+ ).toBeInTheDocument();
56
+ });
57
+
58
+ it("forwards its ref to the underlying div element", () => {
59
+ const ref = React.createRef<HTMLDivElement>();
60
+ render(
61
+ <NarrativeDataCallout ref={ref} visual={<div>chart</div>}>
62
+ Body
63
+ </NarrativeDataCallout>
64
+ );
65
+
66
+ expect(ref.current).toBeInstanceOf(HTMLDivElement);
67
+ expect(ref.current?.tagName).toBe("DIV");
68
+ });
69
+ });
@@ -0,0 +1,2 @@
1
+ export { default as NarrativeDataCallout } from "./NarrativeDataCallout";
2
+ export type { TypeNarrativeDataCalloutProps } from "./NarrativeDataCalloutTypes";
@@ -0,0 +1,33 @@
1
+ import React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import NarrativeDivider from "./NarrativeDivider";
4
+ import "../narrative-divider.css";
5
+
6
+ const meta = {
7
+ title: "Narrative Kit/NarrativeDivider",
8
+ component: NarrativeDivider,
9
+ decorators: [
10
+ (Story) => (
11
+ <div style={{ width: 480 }}>
12
+ <Story />
13
+ </div>
14
+ ),
15
+ ],
16
+ } satisfies Meta<typeof NarrativeDivider>;
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof meta>;
20
+
21
+ /** A full-width 1px rule spanning its container. */
22
+ export const Default: Story = {};
23
+
24
+ /** Sitting between two blocks of content, as it would in a brief. */
25
+ export const BetweenSections: Story = {
26
+ render: () => (
27
+ <div style={{ width: 480 }}>
28
+ <p>Insight metrics</p>
29
+ <NarrativeDivider />
30
+ <p>Headline and summary</p>
31
+ </div>
32
+ ),
33
+ };
@@ -0,0 +1,30 @@
1
+ import * as React from "react";
2
+ import { cn } from "../_internal/cn";
3
+ import type { TypeNarrativeDividerProps } from "./NarrativeDividerTypes";
4
+
5
+ /**
6
+ * NarrativeDivider — a full-width, 1px horizontal rule that separates sections
7
+ * within a narrative brief (e.g. a row of metrics from the headline/summary
8
+ * block below it).
9
+ *
10
+ * Renders a semantic <hr> stripped of its user-agent margins and borders, then
11
+ * draws a single 1px line with the container-border-base token (#dee1e1, which
12
+ * also tracks dark mode). Styled via narrative-divider.css (Seeds CSS custom
13
+ * properties); consumers import the classes through
14
+ * @sproutsocial/racine/css/components. Overrides come via standard className /
15
+ * style.
16
+ */
17
+ const NarrativeDivider = React.forwardRef<
18
+ HTMLHRElement,
19
+ TypeNarrativeDividerProps
20
+ >(({ className, ...props }, ref) => (
21
+ <hr
22
+ ref={ref}
23
+ className={cn("seeds-narrative-divider", className)}
24
+ {...props}
25
+ />
26
+ ));
27
+
28
+ NarrativeDivider.displayName = "NarrativeDivider";
29
+
30
+ export default NarrativeDivider;
@@ -0,0 +1,4 @@
1
+ import type * as React from "react";
2
+
3
+ /** NarrativeDivider takes no extra props — just standard <hr> attributes. */
4
+ export type TypeNarrativeDividerProps = React.HTMLAttributes<HTMLHRElement>;