@sproutsocial/seeds-react-narrative-kit 0.1.0 → 0.2.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 (46) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +9 -0
  3. package/dist/esm/index.js +296 -2
  4. package/dist/esm/index.js.map +1 -1
  5. package/dist/eyebrow-token.css +37 -0
  6. package/dist/index.d.mts +205 -1
  7. package/dist/index.d.ts +205 -1
  8. package/dist/index.js +303 -3
  9. package/dist/index.js.map +1 -1
  10. package/dist/metric-highlight.css +178 -0
  11. package/dist/narrative-container.css +1 -1
  12. package/dist/narrative-headline.css +125 -0
  13. package/dist/pull-quote.css +89 -0
  14. package/package.json +9 -3
  15. package/src/EyebrowToken/EyebrowToken.stories.tsx +25 -0
  16. package/src/EyebrowToken/EyebrowToken.tsx +29 -0
  17. package/src/EyebrowToken/EyebrowTokenTypes.ts +7 -0
  18. package/src/EyebrowToken/__tests__/EyebrowToken.test.tsx +35 -0
  19. package/src/EyebrowToken/index.ts +2 -0
  20. package/src/MetricHighlight/MetricHighlight.stories.tsx +87 -0
  21. package/src/MetricHighlight/MetricHighlight.tsx +196 -0
  22. package/src/MetricHighlight/MetricHighlightTypes.ts +61 -0
  23. package/src/MetricHighlight/__tests__/MetricHighlight.test.tsx +151 -0
  24. package/src/MetricHighlight/index.ts +5 -0
  25. package/src/NarrativeContainer/NarrativeContainer.stories.tsx +3 -3
  26. package/src/NarrativeContainer/NarrativeContainer.tsx +1 -23
  27. package/src/NarrativeHeadline/NarrativeHeadline.stories.tsx +68 -0
  28. package/src/NarrativeHeadline/NarrativeHeadline.tsx +36 -0
  29. package/src/NarrativeHeadline/NarrativeHeadlineHighlight.tsx +31 -0
  30. package/src/NarrativeHeadline/NarrativeHeadlineRoll.tsx +100 -0
  31. package/src/NarrativeHeadline/NarrativeHeadlineTypes.ts +54 -0
  32. package/src/NarrativeHeadline/__tests__/NarrativeHeadline.test.tsx +138 -0
  33. package/src/NarrativeHeadline/index.ts +9 -0
  34. package/src/Playground/Playground.stories.tsx +113 -0
  35. package/src/PullQuote/PullQuote.stories.tsx +43 -0
  36. package/src/PullQuote/PullQuote.tsx +42 -0
  37. package/src/PullQuote/PullQuoteTypes.ts +19 -0
  38. package/src/PullQuote/__tests__/PullQuote.test.tsx +48 -0
  39. package/src/PullQuote/index.ts +2 -0
  40. package/src/_internal/cn.ts +22 -0
  41. package/src/eyebrow-token.css +37 -0
  42. package/src/index.ts +20 -0
  43. package/src/metric-highlight.css +178 -0
  44. package/src/narrative-container.css +1 -1
  45. package/src/narrative-headline.css +125 -0
  46. package/src/pull-quote.css +89 -0
@@ -0,0 +1,61 @@
1
+ import type * as React from "react";
2
+
3
+ export interface TypeMetricHighlightTrend {
4
+ /**
5
+ * Direction of movement. Selects the arrow glyph and the sentiment color of
6
+ * the badge (green for `up`, red for `down`).
7
+ * @default "up"
8
+ */
9
+ direction?: "up" | "down";
10
+ /**
11
+ * Optional value rendered next to the arrow (e.g. `"240%"`, `12`). When
12
+ * omitted the badge is icon-only.
13
+ */
14
+ value?: React.ReactNode;
15
+ /**
16
+ * Accessible label describing the trend (e.g. `"up 240%"`). The arrow glyph is
17
+ * decorative, so provide this whenever the trend conveys meaning on its own.
18
+ */
19
+ "aria-label"?: string;
20
+ }
21
+
22
+ export interface TypeMetricHighlightProps
23
+ extends React.HTMLAttributes<HTMLDivElement> {
24
+ /** Short metric label rendered above/beside the value (e.g. `"Insight metric"`). */
25
+ label: React.ReactNode;
26
+ /** The metric value, rendered as the emphasized monospace figure (e.g. `"123"`, `"1.2M"`). */
27
+ value: React.ReactNode;
28
+ /** Optional trend badge — a directional arrow plus an optional value. */
29
+ trend?: TypeMetricHighlightTrend;
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.
34
+ */
35
+ data?: number[];
36
+ /**
37
+ * Render the sparkline.
38
+ * @default true
39
+ */
40
+ chart?: boolean;
41
+ /**
42
+ * Layout direction.
43
+ * - `horizontal` — metric block beside the sparkline.
44
+ * - `vertical` — metric block stacked above a full-width sparkline.
45
+ * @default "horizontal"
46
+ */
47
+ appearance?: "horizontal" | "vertical";
48
+ /**
49
+ * Size of the value and overall density.
50
+ * - `default` — large value, label stacked above it.
51
+ * - `small` — condensed value, label and value on a single row.
52
+ * @default "default"
53
+ */
54
+ size?: "default" | "small";
55
+ /**
56
+ * Render the padded surface background. Set `false` to drop the surface and
57
+ * compose the metric inside another container.
58
+ * @default true
59
+ */
60
+ container?: boolean;
61
+ }
@@ -0,0 +1,151 @@
1
+ import React from "react";
2
+ import { render, screen } from "@sproutsocial/seeds-react-testing-library";
3
+ import MetricHighlight from "../MetricHighlight";
4
+
5
+ const DATA = [1, 4, 2, 6, 3];
6
+
7
+ describe("MetricHighlight", () => {
8
+ it("renders the label and value", () => {
9
+ render(<MetricHighlight label="Engagement" value="123" />);
10
+
11
+ expect(screen.getByText("Engagement")).toBeInTheDocument();
12
+ expect(screen.getByText("123")).toBeInTheDocument();
13
+ });
14
+
15
+ it("renders the trend value when provided", () => {
16
+ render(
17
+ <MetricHighlight
18
+ label="Engagement"
19
+ value="123"
20
+ trend={{ direction: "up", value: "240%" }}
21
+ />
22
+ );
23
+
24
+ expect(screen.getByText("240%")).toBeInTheDocument();
25
+ });
26
+
27
+ it("renders an icon-only trend badge with no value text", () => {
28
+ const { container } = render(
29
+ <MetricHighlight
30
+ label="Engagement"
31
+ value="123"
32
+ trend={{ direction: "up", "aria-label": "trending up" }}
33
+ />
34
+ );
35
+
36
+ expect(container.querySelector(".seeds-metric-highlight-trend")).toBeInTheDocument();
37
+ expect(
38
+ container.querySelector(".seeds-metric-highlight-trend-value")
39
+ ).not.toBeInTheDocument();
40
+ expect(screen.getByLabelText("trending up")).toBeInTheDocument();
41
+ });
42
+
43
+ it("applies the down modifier for a downward trend", () => {
44
+ const { container } = render(
45
+ <MetricHighlight
46
+ label="Engagement"
47
+ value="123"
48
+ trend={{ direction: "down", value: "12%" }}
49
+ />
50
+ );
51
+
52
+ expect(
53
+ container.querySelector(".seeds-metric-highlight-trend-down")
54
+ ).toBeInTheDocument();
55
+ });
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
+ it("omits the trend badge when no trend is provided", () => {
84
+ const { container } = render(
85
+ <MetricHighlight label="Engagement" value="123" />
86
+ );
87
+
88
+ expect(
89
+ container.querySelector(".seeds-metric-highlight-trend")
90
+ ).not.toBeInTheDocument();
91
+ });
92
+
93
+ it("renders the sparkline when data is provided", () => {
94
+ const { container } = render(
95
+ <MetricHighlight label="Engagement" value="123" data={DATA} />
96
+ );
97
+
98
+ expect(
99
+ container.querySelector(".seeds-metric-highlight-spark")
100
+ ).toBeInTheDocument();
101
+ });
102
+
103
+ it("renders a default sparkline when no data is provided", () => {
104
+ const { container } = render(
105
+ <MetricHighlight label="Engagement" value="123" />
106
+ );
107
+
108
+ 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")
124
+ ).not.toBeInTheDocument();
125
+ });
126
+
127
+ it("applies the vertical, small, and bare modifiers per props", () => {
128
+ const { container } = render(
129
+ <MetricHighlight
130
+ label="Engagement"
131
+ value="123"
132
+ appearance="vertical"
133
+ size="small"
134
+ container={false}
135
+ />
136
+ );
137
+
138
+ const root = container.querySelector(".seeds-metric-highlight");
139
+ expect(root).toHaveClass("seeds-metric-highlight-vertical");
140
+ expect(root).toHaveClass("seeds-metric-highlight-small");
141
+ expect(root).toHaveClass("seeds-metric-highlight-bare");
142
+ });
143
+
144
+ it("forwards its ref to the underlying div element", () => {
145
+ const ref = React.createRef<HTMLDivElement>();
146
+ render(<MetricHighlight ref={ref} label="Engagement" value="123" />);
147
+
148
+ expect(ref.current).toBeInstanceOf(HTMLDivElement);
149
+ expect(ref.current?.tagName).toBe("DIV");
150
+ });
151
+ });
@@ -0,0 +1,5 @@
1
+ export { default as MetricHighlight } from "./MetricHighlight";
2
+ export type {
3
+ TypeMetricHighlightProps,
4
+ TypeMetricHighlightTrend,
5
+ } from "./MetricHighlightTypes";
@@ -12,7 +12,7 @@ export default meta;
12
12
  type Story = StoryObj<typeof meta>;
13
13
 
14
14
  const SlotContent = () => (
15
- <div style={{ display: "grid", gap: 8 }}>
15
+ <div className="grid gap-300">
16
16
  <strong>Engagement is up 24% this month</strong>
17
17
  <span>
18
18
  Driven by a strong week on LinkedIn and a viral reply thread. Audience
@@ -23,14 +23,14 @@ const SlotContent = () => (
23
23
 
24
24
  export const Default: Story = {
25
25
  args: {
26
- style: { width: 384 },
26
+ className: "w-96",
27
27
  children: <SlotContent />,
28
28
  },
29
29
  };
30
30
 
31
31
  export const WithoutAccent: Story = {
32
32
  args: {
33
- style: { width: 384 },
33
+ className: "w-96",
34
34
  accent: false,
35
35
  children: <SlotContent />,
36
36
  },
@@ -1,29 +1,7 @@
1
1
  import * as React from "react";
2
+ import { cn } from "../_internal/cn";
2
3
  import type { TypeNarrativeContainerProps } from "./NarrativeContainerTypes";
3
4
 
4
- /** Merge class names, supporting `{ "class": boolean }` toggles. */
5
- function cn(
6
- ...inputs: (string | undefined | null | false | Record<string, boolean>)[]
7
- ): string {
8
- const classes: string[] = [];
9
-
10
- for (const input of inputs) {
11
- if (!input) continue;
12
-
13
- if (typeof input === "string") {
14
- classes.push(input);
15
- } else if (typeof input === "object") {
16
- for (const [key, value] of Object.entries(input)) {
17
- if (value) {
18
- classes.push(key);
19
- }
20
- }
21
- }
22
- }
23
-
24
- return classes.join(" ");
25
- }
26
-
27
5
  /**
28
6
  * NarrativeContainer — the foundational surface that narrative primitives are
29
7
  * composed inside. A padded, rounded, elevated card with a content slot and an
@@ -0,0 +1,68 @@
1
+ import React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import NarrativeHeadline from "./NarrativeHeadline";
4
+ import NarrativeHeadlineHighlight from "./NarrativeHeadlineHighlight";
5
+ import NarrativeHeadlineRoll from "./NarrativeHeadlineRoll";
6
+ import "../narrative-headline.css";
7
+
8
+ const meta = {
9
+ title: "Narrative Kit/NarrativeHeadline",
10
+ component: NarrativeHeadline,
11
+ } satisfies Meta<typeof NarrativeHeadline>;
12
+
13
+ export default meta;
14
+ type Story = StoryObj<typeof meta>;
15
+
16
+ export const Default: Story = {
17
+ args: {
18
+ className: "w-150",
19
+ children: "Engagement is up 24% this month",
20
+ },
21
+ };
22
+
23
+ export const Highlight: Story = {
24
+ name: "With highlight",
25
+ args: {
26
+ className: "w-150",
27
+ children: (
28
+ <>
29
+ Headline with a{" "}
30
+ <NarrativeHeadlineHighlight>highlighted</NarrativeHeadlineHighlight>{" "}
31
+ word
32
+ </>
33
+ ),
34
+ },
35
+ };
36
+
37
+ export const Roll: Story = {
38
+ name: "Roll variant",
39
+ args: {
40
+ className: "w-150",
41
+ children: (
42
+ <>
43
+ The social suite for{" "}
44
+ <NarrativeHeadlineRoll
45
+ words={["marketers", "agencies", "enterprise teams", "founders"]}
46
+ />
47
+ </>
48
+ ),
49
+ },
50
+ };
51
+
52
+ export const RollDownNoGradient: Story = {
53
+ name: "Roll variant — downward, no gradient",
54
+ args: {
55
+ className: "w-150",
56
+ children: (
57
+ <>
58
+ Now tracking{" "}
59
+ <NarrativeHeadlineRoll
60
+ words={["mentions", "reach", "sentiment"]}
61
+ direction="down"
62
+ gradient={false}
63
+ intervalMs={1600}
64
+ />
65
+ </>
66
+ ),
67
+ },
68
+ };
@@ -0,0 +1,36 @@
1
+ import * as React from "react";
2
+ import { cn } from "../_internal/cn";
3
+ import type { TypeNarrativeHeadlineProps } from "./NarrativeHeadlineTypes";
4
+
5
+ /**
6
+ * NarrativeHeadline — the titling primitive for a narrative block. A single
7
+ * heading element, nothing more: supporting copy and tags/tokens belong to
8
+ * sibling primitives that compose alongside it, not baked into the title.
9
+ *
10
+ * Wrap part of the headline in `<NarrativeHeadlineHighlight>` for a static
11
+ * gradient accent, or `<NarrativeHeadlineRoll>` for the rolling "roll" variant.
12
+ *
13
+ * Styled via `narrative-headline.css` (Seeds CSS custom properties). Consumers
14
+ * import the classes through `@sproutsocial/racine/css/components`. Overrides
15
+ * are done with standard `className` / `style`.
16
+ */
17
+ const NarrativeHeadline = React.forwardRef<
18
+ HTMLHeadingElement,
19
+ TypeNarrativeHeadlineProps
20
+ >(({ children, headingLevel = 2, className, ...props }, ref) => {
21
+ const Heading = `h${headingLevel}` as const;
22
+
23
+ return (
24
+ <Heading
25
+ ref={ref}
26
+ className={cn("seeds-narrative-headline", className)}
27
+ {...props}
28
+ >
29
+ {children}
30
+ </Heading>
31
+ );
32
+ });
33
+
34
+ NarrativeHeadline.displayName = "NarrativeHeadline";
35
+
36
+ export default NarrativeHeadline;
@@ -0,0 +1,31 @@
1
+ import * as React from "react";
2
+ import { cn } from "../_internal/cn";
3
+ import type { TypeNarrativeHeadlineHighlightProps } from "./NarrativeHeadlineTypes";
4
+
5
+ /**
6
+ * NarrativeHeadlineHighlight — paints a span of headline text with the AI
7
+ * brand gradient (the "highlighted word" treatment). Drop it inline inside a
8
+ * `<NarrativeHeadline>`:
9
+ *
10
+ * <NarrativeHeadline>
11
+ * Headline with a <NarrativeHeadlineHighlight>highlighted</NarrativeHeadlineHighlight> word
12
+ * </NarrativeHeadline>
13
+ */
14
+ const NarrativeHeadlineHighlight = React.forwardRef<
15
+ HTMLSpanElement,
16
+ TypeNarrativeHeadlineHighlightProps
17
+ >(({ children, className, ...props }, ref) => {
18
+ return (
19
+ <span
20
+ ref={ref}
21
+ className={cn("seeds-narrative-headline-highlight", className)}
22
+ {...props}
23
+ >
24
+ {children}
25
+ </span>
26
+ );
27
+ });
28
+
29
+ NarrativeHeadlineHighlight.displayName = "NarrativeHeadlineHighlight";
30
+
31
+ export default NarrativeHeadlineHighlight;
@@ -0,0 +1,100 @@
1
+ import * as React from "react";
2
+ import { cn } from "../_internal/cn";
3
+ import type { TypeNarrativeHeadlineRollProps } from "./NarrativeHeadlineTypes";
4
+
5
+ /**
6
+ * NarrativeHeadlineRoll — the "roll" variant: a vertical word roll for the
7
+ * highlighted slot of a headline. The active word slides into view while the
8
+ * previous one rolls out (no typing). Inspired by the performative-ui WordRoll.
9
+ *
10
+ * <NarrativeHeadline>
11
+ * Built for{" "}
12
+ * <NarrativeHeadlineRoll words={["marketers", "agencies", "teams"]} />
13
+ * </NarrativeHeadline>
14
+ *
15
+ * The container width follows the active word so it sits naturally inline.
16
+ * Gradient-painted by default; honors `prefers-reduced-motion` by holding the
17
+ * first word static.
18
+ */
19
+ const NarrativeHeadlineRoll = React.forwardRef<
20
+ HTMLSpanElement,
21
+ TypeNarrativeHeadlineRollProps
22
+ >(
23
+ (
24
+ {
25
+ words,
26
+ intervalMs = 2200,
27
+ transitionMs = 500,
28
+ direction = "up",
29
+ gradient = true,
30
+ className,
31
+ style,
32
+ ...rest
33
+ },
34
+ ref
35
+ ) => {
36
+ const [i, setI] = React.useState(0);
37
+
38
+ React.useEffect(() => {
39
+ if (words.length <= 1) return;
40
+
41
+ // Respect reduced-motion: hold the first word, skip the cycle entirely.
42
+ const reduced =
43
+ typeof window !== "undefined" &&
44
+ typeof window.matchMedia === "function" &&
45
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
46
+ if (reduced) return;
47
+
48
+ const t = setInterval(
49
+ () => setI((j) => (j + 1) % words.length),
50
+ intervalMs
51
+ );
52
+ return () => clearInterval(t);
53
+ }, [intervalMs, words.length]);
54
+
55
+ const merged: React.CSSProperties = {
56
+ ...style,
57
+ // Consumed by the transition timing in narrative-headline.css.
58
+ ["--seeds-narrative-headline-roll-ms" as string]: `${transitionMs}ms`,
59
+ };
60
+
61
+ const prev = words.length ? (i - 1 + words.length) % words.length : 0;
62
+
63
+ return (
64
+ <span
65
+ ref={ref}
66
+ className={cn(
67
+ "seeds-narrative-headline-roll",
68
+ { "seeds-narrative-headline-roll-down": direction === "down" },
69
+ { "seeds-narrative-headline-roll-gradient": gradient },
70
+ className
71
+ )}
72
+ style={merged}
73
+ {...rest}
74
+ >
75
+ {/* Sizer keeps the container width matched to the active word — without
76
+ it the absolutely-positioned words would collapse the box. */}
77
+ <span className="seeds-narrative-headline-roll-sizer" aria-hidden="true">
78
+ {words[i]}
79
+ </span>
80
+ {words.map((w, idx) => (
81
+ <span
82
+ key={idx}
83
+ className={cn("seeds-narrative-headline-roll-word", {
84
+ "seeds-narrative-headline-roll-word-active": idx === i,
85
+ "seeds-narrative-headline-roll-word-past":
86
+ idx === prev && i !== prev,
87
+ })}
88
+ aria-hidden={idx === i ? undefined : "true"}
89
+ >
90
+ {w}
91
+ </span>
92
+ ))}
93
+ </span>
94
+ );
95
+ }
96
+ );
97
+
98
+ NarrativeHeadlineRoll.displayName = "NarrativeHeadlineRoll";
99
+
100
+ export default NarrativeHeadlineRoll;
@@ -0,0 +1,54 @@
1
+ import type * as React from "react";
2
+
3
+ /** Heading level the headline text renders as. */
4
+ export type TypeNarrativeHeadlineLevel = 1 | 2 | 3 | 4 | 5 | 6;
5
+
6
+ export interface TypeNarrativeHeadlineProps
7
+ extends React.HTMLAttributes<HTMLHeadingElement> {
8
+ /**
9
+ * The headline text. Wrap part of it in `<NarrativeHeadlineHighlight>` for a
10
+ * static gradient accent, or `<NarrativeHeadlineRoll>` for the rolling "roll"
11
+ * variant.
12
+ */
13
+ children?: React.ReactNode;
14
+ /**
15
+ * Heading element the headline text renders as, for document outline /
16
+ * accessibility. Visual size is unaffected.
17
+ * @default 2
18
+ */
19
+ headingLevel?: TypeNarrativeHeadlineLevel;
20
+ }
21
+
22
+ export interface TypeNarrativeHeadlineHighlightProps
23
+ extends React.HTMLAttributes<HTMLSpanElement> {
24
+ /** Text painted with the AI gradient. */
25
+ children?: React.ReactNode;
26
+ }
27
+
28
+ export interface TypeNarrativeHeadlineRollProps
29
+ extends React.HTMLAttributes<HTMLSpanElement> {
30
+ /** Words to cycle through. The first word is shown initially. */
31
+ words: string[];
32
+ /**
33
+ * Milliseconds each word holds before rolling out.
34
+ * @default 2200
35
+ */
36
+ intervalMs?: number;
37
+ /**
38
+ * Milliseconds of the slide animation.
39
+ * @default 500
40
+ */
41
+ transitionMs?: number;
42
+ /**
43
+ * Direction the active word rolls in from.
44
+ * @default "up"
45
+ */
46
+ direction?: "up" | "down";
47
+ /**
48
+ * Paint each word with the AI gradient. Apply it here rather than nesting
49
+ * inside `<NarrativeHeadlineHighlight>` — `background-clip: text` does not
50
+ * apply across the absolutely-positioned rolling words.
51
+ * @default true
52
+ */
53
+ gradient?: boolean;
54
+ }