@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,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seeds NarrativeHeadline component classes.
|
|
3
|
+
* Use these instead of writing out individual Tailwind utility classes.
|
|
4
|
+
*
|
|
5
|
+
* Requires @sproutsocial/seeds-react-theme/dist/theme-all.css imported for
|
|
6
|
+
* CSS variable definitions and dark mode support.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* <h2 class="seeds-narrative-headline">
|
|
10
|
+
* Headline with a
|
|
11
|
+
* <span class="seeds-narrative-headline-highlight">highlighted</span>
|
|
12
|
+
* word
|
|
13
|
+
* </h2>
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
@layer components {
|
|
17
|
+
|
|
18
|
+
/* Headline text. Rendered as an h1–h6, so reset the UA heading margins. */
|
|
19
|
+
.seeds-narrative-headline {
|
|
20
|
+
box-sizing: border-box;
|
|
21
|
+
margin: 0;
|
|
22
|
+
font-family: var(--font-family);
|
|
23
|
+
font-size: var(--font-size-700);
|
|
24
|
+
line-height: var(--line-height-700);
|
|
25
|
+
font-weight: var(--font-weight-bold);
|
|
26
|
+
letter-spacing: -0.02em;
|
|
27
|
+
color: var(--color-text-headline);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Static gradient accent for a span of headline text ("highlighted word").
|
|
31
|
+
Paints the AI brand gradient onto the glyphs via background-clip. */
|
|
32
|
+
.seeds-narrative-headline-highlight {
|
|
33
|
+
background: var(--color-gradient-ai);
|
|
34
|
+
-webkit-background-clip: text;
|
|
35
|
+
background-clip: text;
|
|
36
|
+
color: transparent;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* -------------------------------------------------------------------------
|
|
40
|
+
"Roll" variant — vertical word roll. The active word slides into view
|
|
41
|
+
while the previous one rolls out.
|
|
42
|
+
------------------------------------------------------------------------- */
|
|
43
|
+
.seeds-narrative-headline-roll {
|
|
44
|
+
display: inline-block;
|
|
45
|
+
position: relative;
|
|
46
|
+
/* clip-path rather than overflow:hidden: overflow:hidden on an inline-block
|
|
47
|
+
snaps its baseline to the bottom margin edge, dropping the rolling word
|
|
48
|
+
below the surrounding text. clip-path keeps the natural baseline. */
|
|
49
|
+
clip-path: inset(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Invisible word that holds the box width to the active word. */
|
|
53
|
+
.seeds-narrative-headline-roll-sizer {
|
|
54
|
+
display: inline-block;
|
|
55
|
+
visibility: hidden;
|
|
56
|
+
white-space: nowrap;
|
|
57
|
+
pointer-events: none;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.seeds-narrative-headline-roll-word {
|
|
61
|
+
display: inline-block;
|
|
62
|
+
position: absolute;
|
|
63
|
+
top: 0;
|
|
64
|
+
left: 0;
|
|
65
|
+
white-space: nowrap;
|
|
66
|
+
transform: translateY(100%);
|
|
67
|
+
opacity: 0;
|
|
68
|
+
transition:
|
|
69
|
+
transform var(--seeds-narrative-headline-roll-ms, 500ms) ease,
|
|
70
|
+
opacity var(--seeds-narrative-headline-roll-ms, 500ms) ease;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.seeds-narrative-headline-roll-word-active {
|
|
74
|
+
transform: translateY(0);
|
|
75
|
+
opacity: 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.seeds-narrative-headline-roll-word-past {
|
|
79
|
+
transform: translateY(-100%);
|
|
80
|
+
opacity: 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* Reverse the roll direction. */
|
|
84
|
+
.seeds-narrative-headline-roll-down .seeds-narrative-headline-roll-word {
|
|
85
|
+
transform: translateY(-100%);
|
|
86
|
+
}
|
|
87
|
+
.seeds-narrative-headline-roll-down .seeds-narrative-headline-roll-word-active {
|
|
88
|
+
transform: translateY(0);
|
|
89
|
+
}
|
|
90
|
+
.seeds-narrative-headline-roll-down .seeds-narrative-headline-roll-word-past {
|
|
91
|
+
transform: translateY(100%);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Gradient-painted rolling words with a slow shimmer. */
|
|
95
|
+
.seeds-narrative-headline-roll-gradient .seeds-narrative-headline-roll-word {
|
|
96
|
+
background: var(--color-gradient-ai);
|
|
97
|
+
background-size: 200% 200%;
|
|
98
|
+
-webkit-background-clip: text;
|
|
99
|
+
background-clip: text;
|
|
100
|
+
color: transparent;
|
|
101
|
+
animation: seeds-narrative-headline-grad-shift 8s ease infinite;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@keyframes seeds-narrative-headline-grad-shift {
|
|
105
|
+
0%,
|
|
106
|
+
100% {
|
|
107
|
+
background-position: 0% 50%;
|
|
108
|
+
}
|
|
109
|
+
50% {
|
|
110
|
+
background-position: 100% 50%;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* The JS cycle is already disabled under reduced-motion (the first word holds);
|
|
115
|
+
also flatten the transition and shimmer so any in-flight motion settles. */
|
|
116
|
+
@media (prefers-reduced-motion: reduce) {
|
|
117
|
+
.seeds-narrative-headline-roll-word {
|
|
118
|
+
transition: none;
|
|
119
|
+
}
|
|
120
|
+
.seeds-narrative-headline-roll-gradient .seeds-narrative-headline-roll-word {
|
|
121
|
+
animation: none;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seeds PullQuote component classes.
|
|
3
|
+
* Use these instead of writing out individual Tailwind utility classes.
|
|
4
|
+
*
|
|
5
|
+
* Requires @sproutsocial/seeds-react-theme/dist/theme-all.css imported for
|
|
6
|
+
* CSS variable definitions and dark mode support.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* <figure class="seeds-pull-quote">…</figure> // subtle (default)
|
|
10
|
+
* <figure class="seeds-pull-quote seeds-pull-quote-vivid">…</figure>
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
@layer components {
|
|
14
|
+
|
|
15
|
+
.seeds-pull-quote {
|
|
16
|
+
position: relative;
|
|
17
|
+
overflow: hidden;
|
|
18
|
+
box-sizing: border-box;
|
|
19
|
+
width: 100%;
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
align-items: flex-start;
|
|
23
|
+
margin: 0; /* reset the browser default <figure> margin */
|
|
24
|
+
/* 40px block / 48px inline — 48px is off the space scale, so derive it from
|
|
25
|
+
two tokens (40 + 8) rather than hardcoding an off-scale value. */
|
|
26
|
+
padding: var(--space-600) calc(var(--space-600) + var(--space-300));
|
|
27
|
+
border-radius: var(--radius-800);
|
|
28
|
+
box-shadow: var(--shadow-low);
|
|
29
|
+
font-family: var(--font-family);
|
|
30
|
+
/* Subtle (default): the 20% AI gradient tint composited over the base
|
|
31
|
+
surface. --color-gradient-ai-subtle is already translucent, so layering it
|
|
32
|
+
over the opaque base color produces the soft wash. */
|
|
33
|
+
background-color: var(--color-container-bg-base);
|
|
34
|
+
background-image: var(--color-gradient-ai-subtle);
|
|
35
|
+
color: var(--color-text-headline);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Vivid: the full-saturation AI gradient with white content. White has no
|
|
39
|
+
semantic Seeds token; it is part of the AI gradient treatment (mirrors the
|
|
40
|
+
raw brand hex used in --color-gradient-ai). */
|
|
41
|
+
.seeds-pull-quote-vivid {
|
|
42
|
+
background-color: transparent;
|
|
43
|
+
background-image: var(--color-gradient-ai);
|
|
44
|
+
color: #fff;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* The accented bar holding the quote and attribution. */
|
|
48
|
+
.seeds-pull-quote-bar {
|
|
49
|
+
box-sizing: border-box;
|
|
50
|
+
display: flex;
|
|
51
|
+
flex-direction: column;
|
|
52
|
+
align-items: flex-start;
|
|
53
|
+
gap: var(--space-450); /* 24px */
|
|
54
|
+
width: 100%;
|
|
55
|
+
padding-left: var(--space-450); /* 24px */
|
|
56
|
+
/* 4px is off the border-width scale (max token is 3px); the bar is a
|
|
57
|
+
decorative accent, mirroring NarrativeContainer's hardcoded 4px ring. The
|
|
58
|
+
subtle accent uses the AI gradient mid stop (#9747ff — no exact token). */
|
|
59
|
+
border-left: 4px solid #9747ff;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.seeds-pull-quote-vivid .seeds-pull-quote-bar {
|
|
63
|
+
border-left-color: #fff;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.seeds-pull-quote-text {
|
|
67
|
+
margin: 0; /* reset the browser default <blockquote> margin */
|
|
68
|
+
font-size: var(--font-size-600);
|
|
69
|
+
line-height: var(--line-height-600);
|
|
70
|
+
font-style: italic;
|
|
71
|
+
font-weight: var(--font-weight-normal);
|
|
72
|
+
color: inherit;
|
|
73
|
+
overflow-wrap: break-word;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* Attribution row — a flex container for author name, avatar, network, etc.
|
|
77
|
+
Direct text inherits a bold, small treatment to match the display-name style;
|
|
78
|
+
richer content (e.g. an Avatar) composes freely. */
|
|
79
|
+
.seeds-pull-quote-attribution {
|
|
80
|
+
display: flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
gap: var(--space-300); /* 8px */
|
|
83
|
+
font-size: var(--font-size-200);
|
|
84
|
+
line-height: var(--line-height-200);
|
|
85
|
+
font-weight: var(--font-weight-bold);
|
|
86
|
+
color: inherit;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sproutsocial/seeds-react-narrative-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Seeds React Narrative Kit — composable narrative primitives for executive-brief style pages",
|
|
5
5
|
"author": "Sprout Social, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,10 +13,14 @@
|
|
|
13
13
|
"import": "./dist/esm/index.js",
|
|
14
14
|
"require": "./dist/index.js"
|
|
15
15
|
},
|
|
16
|
-
"./dist/narrative-container.css": "./dist/narrative-container.css"
|
|
16
|
+
"./dist/narrative-container.css": "./dist/narrative-container.css",
|
|
17
|
+
"./dist/narrative-headline.css": "./dist/narrative-headline.css",
|
|
18
|
+
"./dist/pull-quote.css": "./dist/pull-quote.css",
|
|
19
|
+
"./dist/metric-highlight.css": "./dist/metric-highlight.css",
|
|
20
|
+
"./dist/eyebrow-token.css": "./dist/eyebrow-token.css"
|
|
17
21
|
},
|
|
18
22
|
"scripts": {
|
|
19
|
-
"build": "tsup --dts && cp src/narrative-container.css dist/narrative-container.css",
|
|
23
|
+
"build": "tsup --dts && cp src/narrative-container.css dist/narrative-container.css && cp src/narrative-headline.css dist/narrative-headline.css && cp src/pull-quote.css dist/pull-quote.css && cp src/metric-highlight.css dist/metric-highlight.css && cp src/eyebrow-token.css dist/eyebrow-token.css",
|
|
20
24
|
"build:debug": "tsup --dts --metafile",
|
|
21
25
|
"dev": "tsup --watch --dts",
|
|
22
26
|
"clean": "rm -rf .turbo dist",
|
|
@@ -30,6 +34,8 @@
|
|
|
30
34
|
},
|
|
31
35
|
"devDependencies": {
|
|
32
36
|
"@sproutsocial/eslint-config-seeds": "*",
|
|
37
|
+
"@sproutsocial/seeds-react-grid": "^0.2.5",
|
|
38
|
+
"@sproutsocial/seeds-react-text": "*",
|
|
33
39
|
"@sproutsocial/seeds-react-testing-library": "*",
|
|
34
40
|
"@sproutsocial/seeds-testing": "*",
|
|
35
41
|
"@sproutsocial/seeds-tsconfig": "*",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import EyebrowToken from "./EyebrowToken";
|
|
4
|
+
import "../eyebrow-token.css";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Narrative Kit/EyebrowToken",
|
|
8
|
+
component: EyebrowToken,
|
|
9
|
+
args: {
|
|
10
|
+
children: "May 31, 2026",
|
|
11
|
+
},
|
|
12
|
+
} satisfies Meta<typeof EyebrowToken>;
|
|
13
|
+
|
|
14
|
+
export default meta;
|
|
15
|
+
type Story = StoryObj<typeof meta>;
|
|
16
|
+
|
|
17
|
+
/** A small gray pill carrying a date — the typical eyebrow above a headline. */
|
|
18
|
+
export const Default: Story = {};
|
|
19
|
+
|
|
20
|
+
/** A category kicker instead of a date. */
|
|
21
|
+
export const Category: Story = {
|
|
22
|
+
args: {
|
|
23
|
+
children: "Quarterly review",
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../_internal/cn";
|
|
3
|
+
import type { TypeEyebrowTokenProps } from "./EyebrowTokenTypes";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* EyebrowToken — a small gray pill that sits above a headline to carry a short
|
|
7
|
+
* bit of context: a date (e.g. "May 31, 2026"), category, or kicker.
|
|
8
|
+
*
|
|
9
|
+
* A faithful match of the Figma "Token / Non Clickable / Default" design — a
|
|
10
|
+
* solid `#dee1e1` fill with a matching border, 6px radius, and small body text.
|
|
11
|
+
* Styled via `eyebrow-token.css` (Seeds CSS custom properties); consumers import
|
|
12
|
+
* the classes through `@sproutsocial/racine/css/components`. Overrides come via
|
|
13
|
+
* standard `className` / `style`.
|
|
14
|
+
*/
|
|
15
|
+
const EyebrowToken = React.forwardRef<HTMLSpanElement, TypeEyebrowTokenProps>(
|
|
16
|
+
({ children, className, ...props }, ref) => (
|
|
17
|
+
<span
|
|
18
|
+
ref={ref}
|
|
19
|
+
className={cn("seeds-eyebrow-token", className)}
|
|
20
|
+
{...props}
|
|
21
|
+
>
|
|
22
|
+
{children}
|
|
23
|
+
</span>
|
|
24
|
+
)
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
EyebrowToken.displayName = "EyebrowToken";
|
|
28
|
+
|
|
29
|
+
export default EyebrowToken;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen } from "@sproutsocial/seeds-react-testing-library";
|
|
3
|
+
import EyebrowToken from "../EyebrowToken";
|
|
4
|
+
|
|
5
|
+
describe("EyebrowToken", () => {
|
|
6
|
+
it("renders its children", () => {
|
|
7
|
+
render(<EyebrowToken>May 31, 2026</EyebrowToken>);
|
|
8
|
+
|
|
9
|
+
expect(screen.getByText("May 31, 2026")).toBeInTheDocument();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("merges a consumer className with the base class", () => {
|
|
13
|
+
const { container } = render(
|
|
14
|
+
<EyebrowToken className="custom">May 31, 2026</EyebrowToken>
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const root = container.querySelector(".seeds-eyebrow-token");
|
|
18
|
+
expect(root).toBeInTheDocument();
|
|
19
|
+
expect(root).toHaveClass("custom");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("forwards extra props to the underlying element", () => {
|
|
23
|
+
render(<EyebrowToken data-testid="eyebrow">May 31, 2026</EyebrowToken>);
|
|
24
|
+
|
|
25
|
+
expect(screen.getByTestId("eyebrow")).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("forwards its ref to the underlying span element", () => {
|
|
29
|
+
const ref = React.createRef<HTMLSpanElement>();
|
|
30
|
+
render(<EyebrowToken ref={ref}>May 31, 2026</EyebrowToken>);
|
|
31
|
+
|
|
32
|
+
expect(ref.current).toBeInstanceOf(HTMLSpanElement);
|
|
33
|
+
expect(ref.current?.tagName).toBe("SPAN");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import MetricHighlight from "./MetricHighlight";
|
|
4
|
+
import "../metric-highlight.css";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Narrative Kit/MetricHighlight",
|
|
8
|
+
component: MetricHighlight,
|
|
9
|
+
args: {
|
|
10
|
+
label: "Insight metric",
|
|
11
|
+
value: "123",
|
|
12
|
+
trend: { direction: "up", value: "240%", "aria-label": "up 240%" },
|
|
13
|
+
},
|
|
14
|
+
} satisfies Meta<typeof MetricHighlight>;
|
|
15
|
+
|
|
16
|
+
export default meta;
|
|
17
|
+
type Story = StoryObj<typeof meta>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Horizontal, default size, padded surface, numeric trend badge, and the
|
|
21
|
+
* default sparkline (no `data` passed).
|
|
22
|
+
*/
|
|
23
|
+
export const Default: Story = {
|
|
24
|
+
args: {
|
|
25
|
+
className: "w-80",
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Condensed: label and value share a single row. */
|
|
30
|
+
export const Small: Story = {
|
|
31
|
+
args: {
|
|
32
|
+
className: "w-80",
|
|
33
|
+
size: "small",
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Stacked: metric block above a full-width sparkline. */
|
|
38
|
+
export const Vertical: Story = {
|
|
39
|
+
args: {
|
|
40
|
+
className: "w-80",
|
|
41
|
+
appearance: "vertical",
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** No surface — composes inside another container. */
|
|
46
|
+
export const WithoutContainer: Story = {
|
|
47
|
+
args: {
|
|
48
|
+
className: "w-80",
|
|
49
|
+
container: false,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Metric without the sparkline. */
|
|
54
|
+
export const WithoutChart: Story = {
|
|
55
|
+
args: {
|
|
56
|
+
className: "w-80",
|
|
57
|
+
chart: false,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Negative trend — the badge swaps to the red treatment and the default
|
|
63
|
+
* sparkline slopes downward in red.
|
|
64
|
+
*/
|
|
65
|
+
export const TrendDown: Story = {
|
|
66
|
+
args: {
|
|
67
|
+
className: "w-80",
|
|
68
|
+
value: "87",
|
|
69
|
+
trend: { direction: "down", value: "12%", "aria-label": "down 12%" },
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Consumer-supplied data overrides the default series. */
|
|
74
|
+
export const CustomData: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
className: "w-80",
|
|
77
|
+
data: [4, 9, 6, 12, 8, 15, 11, 18, 14, 22],
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Icon-only trend badge (no number). */
|
|
82
|
+
export const TrendIconOnly: Story = {
|
|
83
|
+
args: {
|
|
84
|
+
className: "w-80",
|
|
85
|
+
trend: { direction: "up", "aria-label": "trending up" },
|
|
86
|
+
},
|
|
87
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../_internal/cn";
|
|
3
|
+
import type {
|
|
4
|
+
TypeMetricHighlightProps,
|
|
5
|
+
TypeMetricHighlightTrend,
|
|
6
|
+
} from "./MetricHighlightTypes";
|
|
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
|
+
/** Directional arrow glyph for the trend badge (decorative; `aria-hidden`). */
|
|
94
|
+
const TrendArrow = ({ direction }: { direction: "up" | "down" }) => (
|
|
95
|
+
<svg
|
|
96
|
+
className="seeds-metric-highlight-trend-arrow"
|
|
97
|
+
viewBox="0 0 16 16"
|
|
98
|
+
aria-hidden
|
|
99
|
+
focusable={false}
|
|
100
|
+
>
|
|
101
|
+
{direction === "up" ? (
|
|
102
|
+
<path
|
|
103
|
+
d="M4.5 11.5 11.5 4.5M11.5 4.5H5.5M11.5 4.5V10.5"
|
|
104
|
+
fill="none"
|
|
105
|
+
strokeLinecap="round"
|
|
106
|
+
strokeLinejoin="round"
|
|
107
|
+
/>
|
|
108
|
+
) : (
|
|
109
|
+
<path
|
|
110
|
+
d="M4.5 4.5 11.5 11.5M11.5 11.5H5.5M11.5 11.5V5.5"
|
|
111
|
+
fill="none"
|
|
112
|
+
strokeLinecap="round"
|
|
113
|
+
strokeLinejoin="round"
|
|
114
|
+
/>
|
|
115
|
+
)}
|
|
116
|
+
</svg>
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const TrendBadge = ({ direction = "up", value, ...rest }: TypeMetricHighlightTrend) => (
|
|
120
|
+
<span
|
|
121
|
+
className={cn("seeds-metric-highlight-trend", {
|
|
122
|
+
"seeds-metric-highlight-trend-down": direction === "down",
|
|
123
|
+
})}
|
|
124
|
+
{...rest}
|
|
125
|
+
>
|
|
126
|
+
<TrendArrow direction={direction} />
|
|
127
|
+
{value != null && (
|
|
128
|
+
<span className="seeds-metric-highlight-trend-value">{value}</span>
|
|
129
|
+
)}
|
|
130
|
+
</span>
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* MetricHighlight — a narrative primitive that spotlights a single metric: a
|
|
135
|
+
* label, a large monospace value, an optional trend badge (a directional arrow
|
|
136
|
+
* plus an optional number such as "240%"), and an optional sparkline.
|
|
137
|
+
*
|
|
138
|
+
* Styled via `metric-highlight.css` (Seeds CSS custom properties); consumers
|
|
139
|
+
* import the classes through `@sproutsocial/racine/css/components`. Layout flexes
|
|
140
|
+
* across `appearance` (horizontal/vertical) and `size` (default/small); surface
|
|
141
|
+
* overrides are done with standard `className` / `style`.
|
|
142
|
+
*/
|
|
143
|
+
const MetricHighlight = React.forwardRef<HTMLDivElement, TypeMetricHighlightProps>(
|
|
144
|
+
(
|
|
145
|
+
{
|
|
146
|
+
label,
|
|
147
|
+
value,
|
|
148
|
+
trend,
|
|
149
|
+
data,
|
|
150
|
+
chart = true,
|
|
151
|
+
appearance = "horizontal",
|
|
152
|
+
size = "default",
|
|
153
|
+
container = true,
|
|
154
|
+
className,
|
|
155
|
+
...props
|
|
156
|
+
},
|
|
157
|
+
ref
|
|
158
|
+
) => {
|
|
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
|
+
return (
|
|
168
|
+
<div
|
|
169
|
+
ref={ref}
|
|
170
|
+
className={cn(
|
|
171
|
+
"seeds-metric-highlight",
|
|
172
|
+
{
|
|
173
|
+
"seeds-metric-highlight-vertical": appearance === "vertical",
|
|
174
|
+
"seeds-metric-highlight-small": size === "small",
|
|
175
|
+
"seeds-metric-highlight-bare": !container,
|
|
176
|
+
},
|
|
177
|
+
className
|
|
178
|
+
)}
|
|
179
|
+
{...props}
|
|
180
|
+
>
|
|
181
|
+
<div className="seeds-metric-highlight-metric">
|
|
182
|
+
<p className="seeds-metric-highlight-label">{label}</p>
|
|
183
|
+
<div className="seeds-metric-highlight-value-row">
|
|
184
|
+
<p className="seeds-metric-highlight-value">{value}</p>
|
|
185
|
+
{trend != null && <TrendBadge {...trend} />}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
{showChart && <Sparkline data={resolvedData} negative={isDown} />}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
MetricHighlight.displayName = "MetricHighlight";
|
|
195
|
+
|
|
196
|
+
export default MetricHighlight;
|