@sproutsocial/seeds-react-narrative-kit 0.1.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 +28 -0
- package/dist/esm/index.js +324 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/eyebrow-token.css +37 -0
- package/dist/index.d.mts +304 -1
- package/dist/index.d.ts +304 -1
- package/dist/index.js +333 -3
- package/dist/index.js.map +1 -1
- package/dist/metric-highlight.css +168 -0
- package/dist/narrative-container.css +1 -1
- package/dist/narrative-divider.css +27 -0
- package/dist/narrative-headline.css +132 -0
- package/dist/narrative-summary.css +126 -0
- package/dist/pull-quote.css +89 -0
- package/package.json +13 -3
- package/src/EyebrowToken/EyebrowToken.stories.tsx +25 -0
- package/src/EyebrowToken/EyebrowToken.tsx +25 -0
- package/src/EyebrowToken/EyebrowTokenTypes.ts +7 -0
- package/src/EyebrowToken/__tests__/EyebrowToken.test.tsx +35 -0
- package/src/EyebrowToken/index.ts +2 -0
- package/src/MetricHighlight/MetricHighlight.stories.tsx +110 -0
- package/src/MetricHighlight/MetricHighlight.tsx +116 -0
- package/src/MetricHighlight/MetricHighlightTypes.ts +58 -0
- package/src/MetricHighlight/__tests__/MetricHighlight.test.tsx +116 -0
- package/src/MetricHighlight/index.ts +5 -0
- package/src/NarrativeContainer/NarrativeContainer.stories.tsx +3 -3
- package/src/NarrativeContainer/NarrativeContainer.tsx +1 -23
- 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.stories.tsx +68 -0
- package/src/NarrativeHeadline/NarrativeHeadline.tsx +45 -0
- package/src/NarrativeHeadline/NarrativeHeadlineHighlight.tsx +31 -0
- package/src/NarrativeHeadline/NarrativeHeadlineRoll.tsx +103 -0
- package/src/NarrativeHeadline/NarrativeHeadlineTypes.ts +63 -0
- package/src/NarrativeHeadline/__tests__/NarrativeHeadline.test.tsx +133 -0
- package/src/NarrativeHeadline/index.ts +10 -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 +163 -0
- package/src/PullQuote/PullQuote.stories.tsx +45 -0
- package/src/PullQuote/PullQuote.tsx +42 -0
- package/src/PullQuote/PullQuoteTypes.ts +18 -0
- package/src/PullQuote/__tests__/PullQuote.test.tsx +48 -0
- package/src/PullQuote/index.ts +2 -0
- package/src/_internal/cn.ts +22 -0
- package/src/eyebrow-token.css +37 -0
- package/src/index.ts +31 -0
- package/src/metric-highlight.css +168 -0
- package/src/narrative-container.css +1 -1
- package/src/narrative-divider.css +27 -0
- package/src/narrative-headline.css +132 -0
- package/src/narrative-summary.css +126 -0
- package/src/pull-quote.css +89 -0
|
@@ -0,0 +1,103 @@
|
|
|
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
|
|
78
|
+
className="seeds-narrative-headline-roll-sizer"
|
|
79
|
+
aria-hidden="true"
|
|
80
|
+
>
|
|
81
|
+
{words[i]}
|
|
82
|
+
</span>
|
|
83
|
+
{words.map((w, idx) => (
|
|
84
|
+
<span
|
|
85
|
+
key={idx}
|
|
86
|
+
className={cn("seeds-narrative-headline-roll-word", {
|
|
87
|
+
"seeds-narrative-headline-roll-word-active": idx === i,
|
|
88
|
+
"seeds-narrative-headline-roll-word-past":
|
|
89
|
+
idx === prev && i !== prev,
|
|
90
|
+
})}
|
|
91
|
+
aria-hidden={idx === i ? undefined : "true"}
|
|
92
|
+
>
|
|
93
|
+
{w}
|
|
94
|
+
</span>
|
|
95
|
+
))}
|
|
96
|
+
</span>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
NarrativeHeadlineRoll.displayName = "NarrativeHeadlineRoll";
|
|
102
|
+
|
|
103
|
+
export default NarrativeHeadlineRoll;
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
/** Visual size of the headline. Independent of the semantic heading level. */
|
|
7
|
+
export type TypeNarrativeHeadlineSize = "default" | "small";
|
|
8
|
+
|
|
9
|
+
export interface TypeNarrativeHeadlineProps
|
|
10
|
+
extends React.HTMLAttributes<HTMLHeadingElement> {
|
|
11
|
+
/**
|
|
12
|
+
* The headline text. Wrap part of it in `<NarrativeHeadlineHighlight>` for a
|
|
13
|
+
* static gradient accent, or `<NarrativeHeadlineRoll>` for the rolling "roll"
|
|
14
|
+
* variant.
|
|
15
|
+
*/
|
|
16
|
+
children?: React.ReactNode;
|
|
17
|
+
/**
|
|
18
|
+
* Heading element the headline text renders as, for document outline /
|
|
19
|
+
* accessibility. Visual size is unaffected.
|
|
20
|
+
* @default 2
|
|
21
|
+
*/
|
|
22
|
+
headingLevel?: TypeNarrativeHeadlineLevel;
|
|
23
|
+
/**
|
|
24
|
+
* Visual size. `"default"` is the display size (32px / 40px); `"small"` is the
|
|
25
|
+
* compact size (24px / 32px) used inside denser layouts like NarrativeSummary.
|
|
26
|
+
* @default "default"
|
|
27
|
+
*/
|
|
28
|
+
size?: TypeNarrativeHeadlineSize;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TypeNarrativeHeadlineHighlightProps
|
|
32
|
+
extends React.HTMLAttributes<HTMLSpanElement> {
|
|
33
|
+
/** Text painted with the AI gradient. */
|
|
34
|
+
children?: React.ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TypeNarrativeHeadlineRollProps
|
|
38
|
+
extends React.HTMLAttributes<HTMLSpanElement> {
|
|
39
|
+
/** Words to cycle through. The first word is shown initially. */
|
|
40
|
+
words: string[];
|
|
41
|
+
/**
|
|
42
|
+
* Milliseconds each word holds before rolling out.
|
|
43
|
+
* @default 2200
|
|
44
|
+
*/
|
|
45
|
+
intervalMs?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Milliseconds of the slide animation.
|
|
48
|
+
* @default 500
|
|
49
|
+
*/
|
|
50
|
+
transitionMs?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Direction the active word rolls in from.
|
|
53
|
+
* @default "up"
|
|
54
|
+
*/
|
|
55
|
+
direction?: "up" | "down";
|
|
56
|
+
/**
|
|
57
|
+
* Paint each word with the AI gradient. Apply it here rather than nesting
|
|
58
|
+
* inside `<NarrativeHeadlineHighlight>` — `background-clip: text` does not
|
|
59
|
+
* apply across the absolutely-positioned rolling words.
|
|
60
|
+
* @default true
|
|
61
|
+
*/
|
|
62
|
+
gradient?: boolean;
|
|
63
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { act, render, screen } from "@sproutsocial/seeds-react-testing-library";
|
|
3
|
+
import NarrativeHeadline from "../NarrativeHeadline";
|
|
4
|
+
import NarrativeHeadlineHighlight from "../NarrativeHeadlineHighlight";
|
|
5
|
+
import NarrativeHeadlineRoll from "../NarrativeHeadlineRoll";
|
|
6
|
+
|
|
7
|
+
describe("NarrativeHeadline", () => {
|
|
8
|
+
it("renders its headline content", () => {
|
|
9
|
+
render(<NarrativeHeadline>Engagement is up</NarrativeHeadline>);
|
|
10
|
+
|
|
11
|
+
expect(screen.getByText("Engagement is up")).toBeInTheDocument();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("renders the headline as an h2 by default and honors headingLevel", () => {
|
|
15
|
+
const { rerender } = render(<NarrativeHeadline>Title</NarrativeHeadline>);
|
|
16
|
+
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
|
|
17
|
+
"Title"
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
rerender(<NarrativeHeadline headingLevel={1}>Title</NarrativeHeadline>);
|
|
21
|
+
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
|
22
|
+
"Title"
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("forwards its ref to the underlying heading element", () => {
|
|
27
|
+
const ref = React.createRef<HTMLHeadingElement>();
|
|
28
|
+
render(<NarrativeHeadline ref={ref}>Content</NarrativeHeadline>);
|
|
29
|
+
|
|
30
|
+
expect(ref.current).toBeInstanceOf(HTMLHeadingElement);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("NarrativeHeadlineHighlight", () => {
|
|
35
|
+
it("paints its children with the gradient accent class", () => {
|
|
36
|
+
const { container } = render(
|
|
37
|
+
<NarrativeHeadlineHighlight>boosted</NarrativeHeadlineHighlight>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(screen.getByText("boosted")).toBeInTheDocument();
|
|
41
|
+
expect(
|
|
42
|
+
container.querySelector(".seeds-narrative-headline-highlight")
|
|
43
|
+
).toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("NarrativeHeadlineRoll", () => {
|
|
48
|
+
it("renders every word and marks the first one active", () => {
|
|
49
|
+
const { container } = render(
|
|
50
|
+
<NarrativeHeadlineRoll words={["marketers", "agencies", "teams"]} />
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const words = container.querySelectorAll(
|
|
54
|
+
".seeds-narrative-headline-roll-word"
|
|
55
|
+
);
|
|
56
|
+
expect(words).toHaveLength(3);
|
|
57
|
+
expect(words[0]).toHaveClass("seeds-narrative-headline-roll-word-active");
|
|
58
|
+
expect(words[1]).not.toHaveClass(
|
|
59
|
+
"seeds-narrative-headline-roll-word-active"
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("renders a sizer matched to the active word", () => {
|
|
64
|
+
const { container } = render(
|
|
65
|
+
<NarrativeHeadlineRoll words={["alpha", "beta"]} />
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const sizer = container.querySelector(
|
|
69
|
+
".seeds-narrative-headline-roll-sizer"
|
|
70
|
+
);
|
|
71
|
+
expect(sizer).toHaveTextContent("alpha");
|
|
72
|
+
expect(sizer).toHaveAttribute("aria-hidden", "true");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("applies the gradient modifier by default and omits it when disabled", () => {
|
|
76
|
+
const { container, rerender } = render(
|
|
77
|
+
<NarrativeHeadlineRoll words={["a", "b"]} />
|
|
78
|
+
);
|
|
79
|
+
expect(
|
|
80
|
+
container.querySelector(".seeds-narrative-headline-roll-gradient")
|
|
81
|
+
).toBeInTheDocument();
|
|
82
|
+
|
|
83
|
+
rerender(<NarrativeHeadlineRoll words={["a", "b"]} gradient={false} />);
|
|
84
|
+
expect(
|
|
85
|
+
container.querySelector(".seeds-narrative-headline-roll-gradient")
|
|
86
|
+
).not.toBeInTheDocument();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("applies the direction modifier when rolling down", () => {
|
|
90
|
+
const { container } = render(
|
|
91
|
+
<NarrativeHeadlineRoll words={["a", "b"]} direction="down" />
|
|
92
|
+
);
|
|
93
|
+
expect(
|
|
94
|
+
container.querySelector(".seeds-narrative-headline-roll-down")
|
|
95
|
+
).toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("advances to the next word after the interval elapses", () => {
|
|
99
|
+
jest.useFakeTimers();
|
|
100
|
+
try {
|
|
101
|
+
const { container } = render(
|
|
102
|
+
<NarrativeHeadlineRoll words={["first", "second"]} intervalMs={1000} />
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const wordsBefore = container.querySelectorAll(
|
|
106
|
+
".seeds-narrative-headline-roll-word"
|
|
107
|
+
);
|
|
108
|
+
expect(wordsBefore[0]).toHaveClass(
|
|
109
|
+
"seeds-narrative-headline-roll-word-active"
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
act(() => {
|
|
113
|
+
jest.advanceTimersByTime(1000);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const wordsAfter = container.querySelectorAll(
|
|
117
|
+
".seeds-narrative-headline-roll-word"
|
|
118
|
+
);
|
|
119
|
+
expect(wordsAfter[1]).toHaveClass(
|
|
120
|
+
"seeds-narrative-headline-roll-word-active"
|
|
121
|
+
);
|
|
122
|
+
} finally {
|
|
123
|
+
jest.useRealTimers();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("forwards its ref to the underlying element", () => {
|
|
128
|
+
const ref = React.createRef<HTMLSpanElement>();
|
|
129
|
+
render(<NarrativeHeadlineRoll ref={ref} words={["x"]} />);
|
|
130
|
+
|
|
131
|
+
expect(ref.current).toBeInstanceOf(HTMLSpanElement);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { default as NarrativeHeadline } from "./NarrativeHeadline";
|
|
2
|
+
export { default as NarrativeHeadlineHighlight } from "./NarrativeHeadlineHighlight";
|
|
3
|
+
export { default as NarrativeHeadlineRoll } from "./NarrativeHeadlineRoll";
|
|
4
|
+
export type {
|
|
5
|
+
TypeNarrativeHeadlineProps,
|
|
6
|
+
TypeNarrativeHeadlineLevel,
|
|
7
|
+
TypeNarrativeHeadlineSize,
|
|
8
|
+
TypeNarrativeHeadlineHighlightProps,
|
|
9
|
+
TypeNarrativeHeadlineRollProps,
|
|
10
|
+
} from "./NarrativeHeadlineTypes";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import Button from "@sproutsocial/seeds-react-button";
|
|
4
|
+
import NarrativeSummary from "./NarrativeSummary";
|
|
5
|
+
import "../narrative-summary.css";
|
|
6
|
+
import "../narrative-headline.css";
|
|
7
|
+
import "../eyebrow-token.css";
|
|
8
|
+
import "@sproutsocial/seeds-react-button/dist/button.css";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* NarrativeSummary pairs a lead column (eyebrow + headline + optional action)
|
|
12
|
+
* with a Summary section and a Key themes list. It renders inner content only,
|
|
13
|
+
* meant to be dropped into a `NarrativeContainer` in production (see the
|
|
14
|
+
* Playground story for that composition).
|
|
15
|
+
*
|
|
16
|
+
* The layout is a container-query-driven CSS grid, so it responds to the width
|
|
17
|
+
* it is given rather than the viewport.
|
|
18
|
+
*/
|
|
19
|
+
const meta = {
|
|
20
|
+
title: "Narrative Kit/NarrativeSummary",
|
|
21
|
+
component: NarrativeSummary,
|
|
22
|
+
args: {
|
|
23
|
+
eyebrow: "June 10th",
|
|
24
|
+
headline: "Headline goes here and wraps to second line",
|
|
25
|
+
summary:
|
|
26
|
+
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec odio leo, gravida vitae sodales vitae, tempor eget ligula. Vestibulum nec enim eget mi placerat finibus. Donec tempor tincidunt elit. Praesent pretium libero vitae arcu blandit pellentesque. Aenean fermentum, nisi eu blandit placerat, ligula ligula euismod ante, id semper nibh diam ac ipsum.",
|
|
27
|
+
keyThemes: [
|
|
28
|
+
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
|
29
|
+
"Donec odio leo, gravida vitae sodales vitae, tempor eget ligula.",
|
|
30
|
+
"Vestibulum nec enim eget mi placerat finibus.",
|
|
31
|
+
"Donec tempor tincidunt elit. Praesent pretium libero vitae arcu blandit pellentesque.",
|
|
32
|
+
],
|
|
33
|
+
// No system props → the Button renders its Tailwind path.
|
|
34
|
+
action: <Button appearance="primary">Action</Button>,
|
|
35
|
+
},
|
|
36
|
+
// Constrain the width so the grid has a realistic amount of room to lay out.
|
|
37
|
+
decorators: [
|
|
38
|
+
(Story) => (
|
|
39
|
+
<div className="max-w-256">
|
|
40
|
+
<Story />
|
|
41
|
+
</div>
|
|
42
|
+
),
|
|
43
|
+
],
|
|
44
|
+
} satisfies Meta<typeof NarrativeSummary>;
|
|
45
|
+
|
|
46
|
+
export default meta;
|
|
47
|
+
type Story = StoryObj<typeof meta>;
|
|
48
|
+
|
|
49
|
+
/** Lead | Summary | Key themes — three side-by-side columns. */
|
|
50
|
+
export const ThreeColumn: Story = {};
|
|
51
|
+
|
|
52
|
+
/** A 1/3 | 2/3 split: the lead column beside a stacked Summary + Key themes. */
|
|
53
|
+
export const TwoColumn: Story = {
|
|
54
|
+
args: {
|
|
55
|
+
layout: "two-column",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The action is optional — omit it and the lead column drops the button. */
|
|
60
|
+
export const WithoutAction: Story = {
|
|
61
|
+
args: {
|
|
62
|
+
action: undefined,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Responsiveness is container-driven. In this narrow container the grid
|
|
68
|
+
* collapses to a single stacked column; widen the container (or view the
|
|
69
|
+
* ThreeColumn story) to see it expand. Resize the canvas to watch it reflow.
|
|
70
|
+
*/
|
|
71
|
+
export const Responsive: Story = {
|
|
72
|
+
decorators: [
|
|
73
|
+
(Story) => (
|
|
74
|
+
<div className="max-w-96">
|
|
75
|
+
<Story />
|
|
76
|
+
</div>
|
|
77
|
+
),
|
|
78
|
+
],
|
|
79
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../_internal/cn";
|
|
3
|
+
import EyebrowToken from "../EyebrowToken/EyebrowToken";
|
|
4
|
+
import NarrativeHeadline from "../NarrativeHeadline/NarrativeHeadline";
|
|
5
|
+
import type { TypeNarrativeSummaryProps } from "./NarrativeSummaryTypes";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* NarrativeSummary — a composed narrative block that pairs a lead column
|
|
9
|
+
* (eyebrow + headline + optional action) with a Summary section and a Key
|
|
10
|
+
* themes list.
|
|
11
|
+
*
|
|
12
|
+
* Renders inner content only — it carries no surface, shadow, or accent of its
|
|
13
|
+
* own. Drop it into a `NarrativeContainer` (see the stories / playground).
|
|
14
|
+
*
|
|
15
|
+
* Layout is CSS Grid driven by container queries, so the columns collapse based
|
|
16
|
+
* on the width the component is actually given, not the viewport:
|
|
17
|
+
* - `"three-column"` → lead | Summary | Key themes.
|
|
18
|
+
* - `"two-column"` → a 1/3 | 2/3 split with Summary and Key themes stacked in
|
|
19
|
+
* the wider column.
|
|
20
|
+
*
|
|
21
|
+
* Styled via `narrative-summary.css` (Seeds CSS custom properties); consumers
|
|
22
|
+
* import the classes through `@sproutsocial/racine/css/components`. Overrides
|
|
23
|
+
* come via standard `className` / `style`.
|
|
24
|
+
*/
|
|
25
|
+
const NarrativeSummary = React.forwardRef<
|
|
26
|
+
HTMLDivElement,
|
|
27
|
+
TypeNarrativeSummaryProps
|
|
28
|
+
>(
|
|
29
|
+
(
|
|
30
|
+
{
|
|
31
|
+
eyebrow,
|
|
32
|
+
headline,
|
|
33
|
+
headingLevel = 2,
|
|
34
|
+
action,
|
|
35
|
+
summary,
|
|
36
|
+
summaryLabel = "Summary",
|
|
37
|
+
keyThemes,
|
|
38
|
+
keyThemesLabel = "Key themes",
|
|
39
|
+
layout = "three-column",
|
|
40
|
+
className,
|
|
41
|
+
...props
|
|
42
|
+
},
|
|
43
|
+
ref
|
|
44
|
+
) => {
|
|
45
|
+
const hasKeyThemes = keyThemes != null && keyThemes.length > 0;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
ref={ref}
|
|
50
|
+
className={cn("seeds-narrative-summary", className)}
|
|
51
|
+
{...props}
|
|
52
|
+
>
|
|
53
|
+
<div
|
|
54
|
+
className={cn("seeds-narrative-summary-grid", {
|
|
55
|
+
"seeds-narrative-summary-two-column": layout === "two-column",
|
|
56
|
+
})}
|
|
57
|
+
>
|
|
58
|
+
<div className="seeds-narrative-summary-lead">
|
|
59
|
+
{eyebrow != null && <EyebrowToken>{eyebrow}</EyebrowToken>}
|
|
60
|
+
<div className="seeds-narrative-summary-headline">
|
|
61
|
+
<NarrativeHeadline size="small" headingLevel={headingLevel}>
|
|
62
|
+
{headline}
|
|
63
|
+
</NarrativeHeadline>
|
|
64
|
+
</div>
|
|
65
|
+
{action != null && (
|
|
66
|
+
<div className="seeds-narrative-summary-action">{action}</div>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="seeds-narrative-summary-content">
|
|
71
|
+
{summary != null && (
|
|
72
|
+
<section className="seeds-narrative-summary-section">
|
|
73
|
+
<p className="seeds-narrative-summary-label">{summaryLabel}</p>
|
|
74
|
+
<p className="seeds-narrative-summary-text">{summary}</p>
|
|
75
|
+
</section>
|
|
76
|
+
)}
|
|
77
|
+
{hasKeyThemes && (
|
|
78
|
+
<section className="seeds-narrative-summary-section">
|
|
79
|
+
<p className="seeds-narrative-summary-label">
|
|
80
|
+
{keyThemesLabel}
|
|
81
|
+
</p>
|
|
82
|
+
<ul className="seeds-narrative-summary-list">
|
|
83
|
+
{keyThemes.map((theme, i) => (
|
|
84
|
+
<li key={i}>{theme}</li>
|
|
85
|
+
))}
|
|
86
|
+
</ul>
|
|
87
|
+
</section>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
NarrativeSummary.displayName = "NarrativeSummary";
|
|
97
|
+
|
|
98
|
+
export default NarrativeSummary;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import type { TypeNarrativeHeadlineLevel } from "../NarrativeHeadline/NarrativeHeadlineTypes";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Column arrangement.
|
|
6
|
+
* - `"three-column"`: lead | Summary | Key themes, three side-by-side columns.
|
|
7
|
+
* - `"two-column"`: a 1/3 | 2/3 split — lead on the left, with Summary and Key
|
|
8
|
+
* themes stacked together in the wider right column.
|
|
9
|
+
*/
|
|
10
|
+
export type TypeNarrativeSummaryLayout = "three-column" | "two-column";
|
|
11
|
+
|
|
12
|
+
export interface TypeNarrativeSummaryProps
|
|
13
|
+
extends React.HTMLAttributes<HTMLDivElement> {
|
|
14
|
+
/**
|
|
15
|
+
* Kicker shown in an `EyebrowToken` above the headline (e.g. a date). Omit to
|
|
16
|
+
* hide the eyebrow entirely.
|
|
17
|
+
*/
|
|
18
|
+
eyebrow?: React.ReactNode;
|
|
19
|
+
/** Headline text — rendered via `NarrativeHeadline` at `size="small"`. */
|
|
20
|
+
headline: React.ReactNode;
|
|
21
|
+
/**
|
|
22
|
+
* Heading element the headline renders as, for document outline /
|
|
23
|
+
* accessibility.
|
|
24
|
+
* @default 2
|
|
25
|
+
*/
|
|
26
|
+
headingLevel?: TypeNarrativeHeadlineLevel;
|
|
27
|
+
/**
|
|
28
|
+
* Optional action, typically a `<Button>`. Rendered below the headline only
|
|
29
|
+
* when provided. Pass a Button without system props so it renders the Tailwind
|
|
30
|
+
* path (e.g. `<Button appearance="primary">Action</Button>`).
|
|
31
|
+
*
|
|
32
|
+
* Note: in the MVP this will most often be left unset (null) — the AC is
|
|
33
|
+
* poorly defined at this time and needs more definition before the data model
|
|
34
|
+
* will properly support it.
|
|
35
|
+
*/
|
|
36
|
+
action?: React.ReactNode;
|
|
37
|
+
/** Summary body content. The section is omitted when this is empty. */
|
|
38
|
+
summary?: React.ReactNode;
|
|
39
|
+
/**
|
|
40
|
+
* Label above the summary.
|
|
41
|
+
* @default "Summary"
|
|
42
|
+
*/
|
|
43
|
+
summaryLabel?: React.ReactNode;
|
|
44
|
+
/**
|
|
45
|
+
* Key themes, rendered as a bulleted list. The section is omitted when this
|
|
46
|
+
* is empty.
|
|
47
|
+
*/
|
|
48
|
+
keyThemes?: string[];
|
|
49
|
+
/**
|
|
50
|
+
* Label above the key themes.
|
|
51
|
+
* @default "Key themes"
|
|
52
|
+
*/
|
|
53
|
+
keyThemesLabel?: React.ReactNode;
|
|
54
|
+
/**
|
|
55
|
+
* Column arrangement. `"two-column"` is a 1/3 | 2/3 split.
|
|
56
|
+
* @default "three-column"
|
|
57
|
+
*/
|
|
58
|
+
layout?: TypeNarrativeSummaryLayout;
|
|
59
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen } from "@sproutsocial/seeds-react-testing-library";
|
|
3
|
+
import NarrativeSummary from "../NarrativeSummary";
|
|
4
|
+
|
|
5
|
+
const baseProps = {
|
|
6
|
+
headline: "Headline goes here",
|
|
7
|
+
summary: "A short summary.",
|
|
8
|
+
keyThemes: ["Theme one", "Theme two"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("NarrativeSummary", () => {
|
|
12
|
+
it("renders the headline, eyebrow, summary, and key themes", () => {
|
|
13
|
+
render(<NarrativeSummary {...baseProps} eyebrow="June 10th" />);
|
|
14
|
+
|
|
15
|
+
expect(screen.getByText("June 10th")).toBeInTheDocument();
|
|
16
|
+
expect(
|
|
17
|
+
screen.getByRole("heading", { name: "Headline goes here" })
|
|
18
|
+
).toBeInTheDocument();
|
|
19
|
+
expect(screen.getByText("A short summary.")).toBeInTheDocument();
|
|
20
|
+
expect(screen.getByText("Theme one")).toBeInTheDocument();
|
|
21
|
+
expect(screen.getByText("Theme two")).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("renders the headline at the small size", () => {
|
|
25
|
+
const { container } = render(<NarrativeSummary {...baseProps} />);
|
|
26
|
+
|
|
27
|
+
expect(
|
|
28
|
+
container.querySelector(".seeds-narrative-headline-small")
|
|
29
|
+
).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("uses the default section labels and allows overrides", () => {
|
|
33
|
+
const { rerender } = render(<NarrativeSummary {...baseProps} />);
|
|
34
|
+
expect(screen.getByText("Summary")).toBeInTheDocument();
|
|
35
|
+
expect(screen.getByText("Key themes")).toBeInTheDocument();
|
|
36
|
+
|
|
37
|
+
rerender(
|
|
38
|
+
<NarrativeSummary
|
|
39
|
+
{...baseProps}
|
|
40
|
+
summaryLabel="Overview"
|
|
41
|
+
keyThemesLabel="Highlights"
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
expect(screen.getByText("Overview")).toBeInTheDocument();
|
|
45
|
+
expect(screen.getByText("Highlights")).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("renders the key themes as list items", () => {
|
|
49
|
+
render(<NarrativeSummary {...baseProps} />);
|
|
50
|
+
|
|
51
|
+
expect(screen.getAllByRole("listitem")).toHaveLength(2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("renders the action only when provided", () => {
|
|
55
|
+
const { rerender } = render(<NarrativeSummary {...baseProps} />);
|
|
56
|
+
expect(
|
|
57
|
+
screen.queryByRole("button", { name: "Action" })
|
|
58
|
+
).not.toBeInTheDocument();
|
|
59
|
+
|
|
60
|
+
rerender(
|
|
61
|
+
<NarrativeSummary {...baseProps} action={<button>Action</button>} />
|
|
62
|
+
);
|
|
63
|
+
expect(screen.getByRole("button", { name: "Action" })).toBeInTheDocument();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("omits a section when its content is empty", () => {
|
|
67
|
+
render(<NarrativeSummary headline="Only summary" summary="Just this." />);
|
|
68
|
+
|
|
69
|
+
expect(screen.getByText("Summary")).toBeInTheDocument();
|
|
70
|
+
expect(screen.queryByText("Key themes")).not.toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("applies the two-column modifier class", () => {
|
|
74
|
+
const { container } = render(
|
|
75
|
+
<NarrativeSummary {...baseProps} layout="two-column" />
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(
|
|
79
|
+
container.querySelector(".seeds-narrative-summary-two-column")
|
|
80
|
+
).toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("merges a consumer className with the base class", () => {
|
|
84
|
+
const { container } = render(
|
|
85
|
+
<NarrativeSummary {...baseProps} className="custom" />
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const root = container.querySelector(".seeds-narrative-summary");
|
|
89
|
+
expect(root).toBeInTheDocument();
|
|
90
|
+
expect(root).toHaveClass("custom");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("forwards its ref to the underlying div element", () => {
|
|
94
|
+
const ref = React.createRef<HTMLDivElement>();
|
|
95
|
+
render(<NarrativeSummary {...baseProps} ref={ref} />);
|
|
96
|
+
|
|
97
|
+
expect(ref.current).toBeInstanceOf(HTMLDivElement);
|
|
98
|
+
});
|
|
99
|
+
});
|