@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windstream/react-shared-components",
3
- "version": "0.2.69",
3
+ "version": "0.2.70",
4
4
  "type": "module",
5
5
  "description": "Shared React components for Kinetic applications",
6
6
  "main": "dist/index.js",
@@ -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: fields?.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,