@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.
- package/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +9 -0
- package/dist/esm/index.js +296 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/eyebrow-token.css +37 -0
- package/dist/index.d.mts +205 -1
- package/dist/index.d.ts +205 -1
- package/dist/index.js +303 -3
- package/dist/index.js.map +1 -1
- package/dist/metric-highlight.css +178 -0
- package/dist/narrative-container.css +1 -1
- package/dist/narrative-headline.css +125 -0
- package/dist/pull-quote.css +89 -0
- package/package.json +9 -3
- package/src/EyebrowToken/EyebrowToken.stories.tsx +25 -0
- package/src/EyebrowToken/EyebrowToken.tsx +29 -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 +87 -0
- package/src/MetricHighlight/MetricHighlight.tsx +196 -0
- package/src/MetricHighlight/MetricHighlightTypes.ts +61 -0
- package/src/MetricHighlight/__tests__/MetricHighlight.test.tsx +151 -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/NarrativeHeadline/NarrativeHeadline.stories.tsx +68 -0
- package/src/NarrativeHeadline/NarrativeHeadline.tsx +36 -0
- package/src/NarrativeHeadline/NarrativeHeadlineHighlight.tsx +31 -0
- package/src/NarrativeHeadline/NarrativeHeadlineRoll.tsx +100 -0
- package/src/NarrativeHeadline/NarrativeHeadlineTypes.ts +54 -0
- package/src/NarrativeHeadline/__tests__/NarrativeHeadline.test.tsx +138 -0
- package/src/NarrativeHeadline/index.ts +9 -0
- package/src/Playground/Playground.stories.tsx +113 -0
- package/src/PullQuote/PullQuote.stories.tsx +43 -0
- package/src/PullQuote/PullQuote.tsx +42 -0
- package/src/PullQuote/PullQuoteTypes.ts +19 -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 +20 -0
- package/src/metric-highlight.css +178 -0
- package/src/narrative-container.css +1 -1
- package/src/narrative-headline.css +125 -0
- 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
|
+
});
|
|
@@ -12,7 +12,7 @@ export default meta;
|
|
|
12
12
|
type Story = StoryObj<typeof meta>;
|
|
13
13
|
|
|
14
14
|
const SlotContent = () => (
|
|
15
|
-
<div
|
|
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
|
-
|
|
26
|
+
className: "w-96",
|
|
27
27
|
children: <SlotContent />,
|
|
28
28
|
},
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
export const WithoutAccent: Story = {
|
|
32
32
|
args: {
|
|
33
|
-
|
|
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
|
+
}
|