@windstream/react-shared-components 0.2.69 → 0.2.70
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/dist/contentful/index.esm.js +1 -1
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +1 -1
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +32 -3
- package/dist/index.d.ts +32 -3
- package/dist/index.esm.js +8 -8
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +8 -8
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/utils/index.esm.js +1 -1
- package/dist/utils/index.esm.js.map +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/rich-text-basic/index.test.tsx +172 -0
- package/src/components/rich-text-basic/index.tsx +102 -0
- package/src/components/rich-text-basic/types.ts +34 -0
- package/src/components/video-link/index.tsx +2 -2
- package/src/hooks/contentful/use-contentful-rich-text.tsx +39 -2
- package/src/index.ts +7 -0
package/package.json
CHANGED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { render, screen } from "@testing-library/react";
|
|
5
|
+
|
|
6
|
+
import { RichTextBasic } from "./index";
|
|
7
|
+
|
|
8
|
+
const paragraphDoc = (text: string) => ({
|
|
9
|
+
nodeType: "document",
|
|
10
|
+
data: {},
|
|
11
|
+
content: [
|
|
12
|
+
{
|
|
13
|
+
nodeType: "paragraph",
|
|
14
|
+
data: {},
|
|
15
|
+
content: [{ nodeType: "text", value: text, marks: [], data: {} }],
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("RichTextBasic", () => {
|
|
21
|
+
it("renders paragraph text", () => {
|
|
22
|
+
render(<RichTextBasic {...paragraphDoc("Hello world")} />);
|
|
23
|
+
expect(screen.getByText("Hello world")).toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("renders a heading using the first child value", () => {
|
|
27
|
+
const doc = {
|
|
28
|
+
nodeType: "document",
|
|
29
|
+
data: {},
|
|
30
|
+
content: [
|
|
31
|
+
{
|
|
32
|
+
nodeType: "heading-1",
|
|
33
|
+
data: {},
|
|
34
|
+
content: [{ nodeType: "text", value: "Title", marks: [], data: {} }],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
render(<RichTextBasic {...doc} />);
|
|
39
|
+
const heading = screen.getByRole("heading", { level: 1 });
|
|
40
|
+
expect(heading).toHaveTextContent("Title");
|
|
41
|
+
expect(heading.className).toContain("heading1");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("includes both the mobile-base and md: desktop heading classes", () => {
|
|
45
|
+
const doc = {
|
|
46
|
+
nodeType: "document",
|
|
47
|
+
data: {},
|
|
48
|
+
content: [
|
|
49
|
+
{
|
|
50
|
+
nodeType: "heading-1",
|
|
51
|
+
data: {},
|
|
52
|
+
content: [{ nodeType: "text", value: "Title", marks: [], data: {} }],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
render(<RichTextBasic {...doc} />);
|
|
57
|
+
const { className } = screen.getByRole("heading", { level: 1 });
|
|
58
|
+
expect(className).toContain("heading2");
|
|
59
|
+
expect(className).toContain("md:heading1");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("renders bold marks", () => {
|
|
63
|
+
const doc = {
|
|
64
|
+
nodeType: "document",
|
|
65
|
+
data: {},
|
|
66
|
+
content: [
|
|
67
|
+
{
|
|
68
|
+
nodeType: "paragraph",
|
|
69
|
+
data: {},
|
|
70
|
+
content: [
|
|
71
|
+
{ nodeType: "text", value: "Bold", marks: [{ type: "bold" }], data: {} },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
render(<RichTextBasic {...doc} />);
|
|
77
|
+
expect(screen.getByText("Bold").tagName).toBe("STRONG");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("renders hyperlinks", () => {
|
|
81
|
+
const doc = {
|
|
82
|
+
nodeType: "document",
|
|
83
|
+
data: {},
|
|
84
|
+
content: [
|
|
85
|
+
{
|
|
86
|
+
nodeType: "paragraph",
|
|
87
|
+
data: {},
|
|
88
|
+
content: [
|
|
89
|
+
{
|
|
90
|
+
nodeType: "hyperlink",
|
|
91
|
+
data: { uri: "https://example.com" },
|
|
92
|
+
content: [{ nodeType: "text", value: "Link", marks: [], data: {} }],
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
render(<RichTextBasic {...doc} isTargetBlank />);
|
|
99
|
+
const link = screen.getByRole("link", { name: "Link" });
|
|
100
|
+
expect(link).toHaveAttribute("href", "https://example.com");
|
|
101
|
+
expect(link).toHaveAttribute("target", "_blank");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("renders inline [icon_name] tokens as MaterialIcon glyphs", () => {
|
|
105
|
+
render(<RichTextBasic {...paragraphDoc("look [star] here")} />);
|
|
106
|
+
const icon = screen.getByText("star");
|
|
107
|
+
expect(icon.className).toContain("material-symbols-rounded");
|
|
108
|
+
expect(screen.getByText(/look/)).toBeInTheDocument();
|
|
109
|
+
expect(screen.getByText(/here/)).toBeInTheDocument();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("renders embedded callout entries", () => {
|
|
113
|
+
const doc = {
|
|
114
|
+
nodeType: "document",
|
|
115
|
+
data: {},
|
|
116
|
+
content: [
|
|
117
|
+
{
|
|
118
|
+
nodeType: "embedded-entry-block",
|
|
119
|
+
data: {
|
|
120
|
+
target: {
|
|
121
|
+
fields: { title: "Heads up", body: "Important info" },
|
|
122
|
+
sys: { contentType: { sys: { id: "callout" } } },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
content: [],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
render(<RichTextBasic {...doc} />);
|
|
130
|
+
expect(screen.getByText("Heads up").tagName).toBe("STRONG");
|
|
131
|
+
expect(screen.getByText("Important info")).toBeInTheDocument();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("renders table content via the table renderer when the document contains a table, regardless of tableLayout className", () => {
|
|
135
|
+
const doc = {
|
|
136
|
+
nodeType: "document",
|
|
137
|
+
data: {},
|
|
138
|
+
content: [
|
|
139
|
+
{
|
|
140
|
+
nodeType: "table",
|
|
141
|
+
data: {},
|
|
142
|
+
content: [
|
|
143
|
+
{
|
|
144
|
+
nodeType: "table-row",
|
|
145
|
+
data: {},
|
|
146
|
+
content: [
|
|
147
|
+
{
|
|
148
|
+
nodeType: "table-header-cell",
|
|
149
|
+
data: {},
|
|
150
|
+
content: [
|
|
151
|
+
{
|
|
152
|
+
nodeType: "paragraph",
|
|
153
|
+
data: {},
|
|
154
|
+
content: [
|
|
155
|
+
{ nodeType: "text", value: "Head", marks: [], data: {} },
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
const { rerender } = render(<RichTextBasic {...doc} className="tableLayout" />);
|
|
167
|
+
expect(screen.getByRole("columnheader")).toHaveTextContent("Head");
|
|
168
|
+
|
|
169
|
+
rerender(<RichTextBasic {...doc} />);
|
|
170
|
+
expect(screen.getByRole("columnheader")).toHaveTextContent("Head");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { FC } from "react";
|
|
2
|
+
|
|
3
|
+
import { BLOCKS } from "@contentful/rich-text-types";
|
|
4
|
+
import {
|
|
5
|
+
renderContentfulRichText,
|
|
6
|
+
renderContentfulRichTextTable,
|
|
7
|
+
} from "@shared/hooks/contentful/use-contentful-rich-text";
|
|
8
|
+
import { cx } from "@shared/utils/cx";
|
|
9
|
+
|
|
10
|
+
import type { RichTextBasicProps } from "./types";
|
|
11
|
+
import { toDocument } from "@shared/utils";
|
|
12
|
+
|
|
13
|
+
// Contentful can store one logical run of text as several adjacent "text"
|
|
14
|
+
// nodes with identical (often empty) marks - an authoring artifact. Per-node
|
|
15
|
+
// regexes (icon tokens like [shopping_cart], ${CITY}/${STATE}) only ever see
|
|
16
|
+
// one node's content, so without merging, a token split across node
|
|
17
|
+
// boundaries never matches and renders as literal bracket text.
|
|
18
|
+
const mergeAdjacentTextNodes = (node: any): any => {
|
|
19
|
+
if (!node || !Array.isArray(node.content)) return node;
|
|
20
|
+
const mergedContent: any[] = [];
|
|
21
|
+
for (const child of node.content) {
|
|
22
|
+
const prev = mergedContent[mergedContent.length - 1];
|
|
23
|
+
if (
|
|
24
|
+
child?.nodeType === "text" &&
|
|
25
|
+
prev?.nodeType === "text" &&
|
|
26
|
+
JSON.stringify(prev.marks) === JSON.stringify(child.marks)
|
|
27
|
+
) {
|
|
28
|
+
mergedContent[mergedContent.length - 1] = {
|
|
29
|
+
...prev,
|
|
30
|
+
value: prev.value + child.value,
|
|
31
|
+
};
|
|
32
|
+
} else {
|
|
33
|
+
mergedContent.push(
|
|
34
|
+
child?.nodeType === "text" ? child : mergeAdjacentTextNodes(child)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { ...node, content: mergedContent };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Checks the actual rich text document (not just className) for a table block,
|
|
42
|
+
// so content authored with a table still renders through the table renderer.
|
|
43
|
+
const containsTable = (node: any): boolean => {
|
|
44
|
+
if (!node) return false;
|
|
45
|
+
if (node.nodeType === BLOCKS.TABLE) return true;
|
|
46
|
+
if (Array.isArray(node.content)) {
|
|
47
|
+
return node.content.some((child: any) => containsTable(child));
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const RichTextBasic: FC<RichTextBasicProps> = props => {
|
|
53
|
+
const {
|
|
54
|
+
options,
|
|
55
|
+
textProps,
|
|
56
|
+
isTargetBlank,
|
|
57
|
+
className,
|
|
58
|
+
linkClassName,
|
|
59
|
+
headerColor = "grey",
|
|
60
|
+
enableMaxWidth,
|
|
61
|
+
headingClass,
|
|
62
|
+
"data-testid": dataTestId,
|
|
63
|
+
} = props;
|
|
64
|
+
|
|
65
|
+
const document = mergeAdjacentTextNodes(toDocument(props));
|
|
66
|
+
const isTable = className?.includes("tableLayout") || containsTable(document);
|
|
67
|
+
|
|
68
|
+
const content = isTable
|
|
69
|
+
? renderContentfulRichTextTable(document, props.links)
|
|
70
|
+
: renderContentfulRichText(
|
|
71
|
+
document,
|
|
72
|
+
isTargetBlank,
|
|
73
|
+
className,
|
|
74
|
+
linkClassName,
|
|
75
|
+
undefined,
|
|
76
|
+
props.links
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
style={
|
|
82
|
+
{
|
|
83
|
+
...textProps?.style,
|
|
84
|
+
["--tw-prose-bullets" as string]: "white",
|
|
85
|
+
["--tw-prose-counters" as string]: "white",
|
|
86
|
+
} as React.CSSProperties
|
|
87
|
+
}
|
|
88
|
+
className={cx(
|
|
89
|
+
"last:[&_p]:mb-0",
|
|
90
|
+
"text-body1 leading-normal",
|
|
91
|
+
"[&_b]:font-bold [&_strong]:font-bold [&_i]:italic [&_em]:italic",
|
|
92
|
+
className,
|
|
93
|
+
!enableMaxWidth && "max-w-none"
|
|
94
|
+
)}
|
|
95
|
+
data-testid={dataTestId}
|
|
96
|
+
>
|
|
97
|
+
{content}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export default RichTextBasic;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import type { Node as RichTextNode } from "@contentful/rich-text-types";
|
|
4
|
+
|
|
5
|
+
export type RichTextBasicTextProps = {
|
|
6
|
+
variant?: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
style?: React.CSSProperties;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type RichTextBasicOptions = {
|
|
12
|
+
isTargetBlank?: boolean;
|
|
13
|
+
textProps?: RichTextBasicTextProps;
|
|
14
|
+
listItem?: (node: RichTextNode, children: ReactNode) => ReactNode;
|
|
15
|
+
props?: any;
|
|
16
|
+
headingClass?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type RichTextBasicProps = {
|
|
20
|
+
/** Contentful rich text document fields (nodeType/content/data), spread directly. */
|
|
21
|
+
[key: string]: any;
|
|
22
|
+
textProps?: RichTextBasicTextProps;
|
|
23
|
+
className?: string;
|
|
24
|
+
linkClassName?: string;
|
|
25
|
+
isTargetBlank?: boolean;
|
|
26
|
+
headingClass?: string;
|
|
27
|
+
options?: Omit<
|
|
28
|
+
RichTextBasicOptions,
|
|
29
|
+
"isTargetBlank" | "textProps" | "headingClass"
|
|
30
|
+
>;
|
|
31
|
+
"data-testid"?: string;
|
|
32
|
+
headerColor?: string;
|
|
33
|
+
enableMaxWidth?: boolean;
|
|
34
|
+
};
|
|
@@ -55,7 +55,7 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
|
|
|
55
55
|
<div
|
|
56
56
|
className={cx(
|
|
57
57
|
!useOriginalImageAspectRatio
|
|
58
|
-
? "relative aspect-[16/9] w-full overflow-hidden rounded-image lg:h-[486px] xl:aspect-square xl:max-h-[486px] xl:w-[486px]"
|
|
58
|
+
? "relative mx-auto aspect-[16/9] w-full overflow-hidden rounded-image lg:h-[486px] xl:aspect-square xl:max-h-[486px] xl:w-[486px]"
|
|
59
59
|
: "overflow-hidden rounded-image"
|
|
60
60
|
)}
|
|
61
61
|
>
|
|
@@ -181,7 +181,7 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
|
|
|
181
181
|
<>
|
|
182
182
|
<div
|
|
183
183
|
className={cx(
|
|
184
|
-
"video-section relative w-full cursor-pointer rounded-image transition-all duration-300 lg:w-[486px]",
|
|
184
|
+
"video-section relative mx-auto w-full cursor-pointer rounded-image transition-all duration-300 lg:w-[486px]",
|
|
185
185
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus",
|
|
186
186
|
!isPlaying && "hover:ring-2 hover:ring-border-focus"
|
|
187
187
|
)}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { Checklist } from "@shared/components/checklist";
|
|
14
14
|
import { Link } from "@shared/components/link";
|
|
15
15
|
import { MaterialIcon } from "@shared/components/material-icon";
|
|
16
|
+
import type { IconName } from "@shared/components/material-icon";
|
|
16
17
|
import { Text } from "@shared/components/text";
|
|
17
18
|
import { VideoLink } from "@shared/components/video-link";
|
|
18
19
|
import type { VideoLinkProps } from "@shared/components/video-link";
|
|
@@ -53,7 +54,10 @@ const toVideoLinkProps = (fields: any): VideoLinkProps => ({
|
|
|
53
54
|
fields?.image?.fields?.file?.url ??
|
|
54
55
|
fields?.image?.url ??
|
|
55
56
|
fields?.image,
|
|
56
|
-
link:
|
|
57
|
+
link:
|
|
58
|
+
typeof fields?.link === "string"
|
|
59
|
+
? fields.link
|
|
60
|
+
: (fields?.link?.fields?.href ?? fields?.link?.href),
|
|
57
61
|
videoPopup: Boolean(fields?.videoPopup),
|
|
58
62
|
videoSourceType: fields?.videoSourceType,
|
|
59
63
|
videoAssetUrl:
|
|
@@ -125,7 +129,40 @@ const isExternalUrl = (url: string): boolean => {
|
|
|
125
129
|
}
|
|
126
130
|
};
|
|
127
131
|
|
|
132
|
+
// Renders "[icon_name]" tokens (e.g. "look [star] here") as MaterialIcon glyphs.
|
|
133
|
+
const renderInlineIcons = (text: string): ReactNode[] => {
|
|
134
|
+
const iconPattern = /\[([a-zA-Z0-9_-]+)\]/g;
|
|
135
|
+
const parts: ReactNode[] = [];
|
|
136
|
+
let lastIndex = 0;
|
|
137
|
+
let match: RegExpExecArray | null;
|
|
138
|
+
|
|
139
|
+
while ((match = iconPattern.exec(text)) !== null) {
|
|
140
|
+
if (match.index > lastIndex) {
|
|
141
|
+
parts.push(text.substring(lastIndex, match.index));
|
|
142
|
+
}
|
|
143
|
+
parts.push(
|
|
144
|
+
<MaterialIcon
|
|
145
|
+
key={`icon-${match.index}-${match[1]}`}
|
|
146
|
+
name={match[1] as IconName}
|
|
147
|
+
size={20}
|
|
148
|
+
className="inline-block align-middle"
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
lastIndex = match.index + match[0].length;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (lastIndex < text.length) {
|
|
155
|
+
parts.push(text.substring(lastIndex));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return parts.length > 0 ? parts : [text];
|
|
159
|
+
};
|
|
160
|
+
|
|
128
161
|
const getDefaultOptions = (links?: any): Options => ({
|
|
162
|
+
renderText: children =>
|
|
163
|
+
children.includes("[") && children.includes("]")
|
|
164
|
+
? renderInlineIcons(children)
|
|
165
|
+
: children,
|
|
129
166
|
renderMark: {
|
|
130
167
|
[MARKS.BOLD]: text => <strong className="font-black">{text}</strong>,
|
|
131
168
|
[MARKS.ITALIC]: text => <em>{text}</em>,
|
|
@@ -467,4 +504,4 @@ export function renderContentfulRichTextTable(
|
|
|
467
504
|
};
|
|
468
505
|
|
|
469
506
|
return documentToReactComponents(doc, options);
|
|
470
|
-
}
|
|
507
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -94,6 +94,13 @@ export type { SkeletonProps } from "./components/skeleton";
|
|
|
94
94
|
export { Tooltip } from "./components/tooltip";
|
|
95
95
|
export type { ToolTipProps } from "./components/tooltip";
|
|
96
96
|
|
|
97
|
+
export { RichTextBasic } from "./components/rich-text-basic";
|
|
98
|
+
export type {
|
|
99
|
+
RichTextBasicProps,
|
|
100
|
+
RichTextBasicOptions,
|
|
101
|
+
RichTextBasicTextProps,
|
|
102
|
+
} from "./components/rich-text-basic/types";
|
|
103
|
+
|
|
97
104
|
export {
|
|
98
105
|
FiberForm,
|
|
99
106
|
fiberFormAddressCollectionFormSchema,
|