@windstream/react-shared-components 0.2.29 → 0.2.31

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.29",
3
+ "version": "0.2.31",
4
4
  "type": "module",
5
5
  "description": "Shared React components for Kinetic applications",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,388 @@
1
+ import { useState, type ComponentProps } from "react";
2
+ import { AddressInputBanner } from "./index";
3
+
4
+ import { Button } from "@shared/contentful/blocks/button";
5
+ import { DocsPage } from "@shared/stories/DocsTemplate";
6
+ import type { Meta, StoryObj } from "@storybook/react-vite";
7
+
8
+ // Sample address suggestions shown once the user types 3+ characters.
9
+ const ADDRESS_SUGGESTIONS = [
10
+ "123 Dale Springs, Apt B, Binsport, NC, 17032",
11
+ "123 Dale Springs, Apt C, Binsport, NC, 17032",
12
+ "123 Dale Avenue, Binsport, NC, 17033",
13
+ ];
14
+
15
+ type AddressBarProps = {
16
+ buttonVariant?: "primary_brand" | "primary_inverse" | "secondary";
17
+ ctaLabel?: string;
18
+ desktopPrompt?: string;
19
+ mobilePrompt?: string;
20
+ placeholder?: string;
21
+ promptTextClass?: string;
22
+ };
23
+
24
+ // Shared address bar (title + location input + check-plans button) used across
25
+ // the stories. The button variant is configurable so themed variants (e.g.
26
+ // Yellow) can render an inverse/navy CTA.
27
+ const AddressBar = ({
28
+ buttonVariant = "primary_brand",
29
+ ctaLabel = "Check plans",
30
+ desktopPrompt = "To see plans for your address, check availability",
31
+ mobilePrompt = "Show me the best offers in my area",
32
+ placeholder = "123 Dale Springs, Apt B, Binsport, NC, 17032",
33
+ promptTextClass = "text-text",
34
+ }: AddressBarProps) => {
35
+ const [query, setQuery] = useState("");
36
+ const showResults = query.trim().length >= 3;
37
+
38
+ // Mobile "Go" button reuses the BrandButton color tokens but is rendered as a
39
+ // plain 56x56 button so it can honor the fixed Figma width (the shared Button
40
+ // has a baked-in 60px horizontal padding that cannot be overridden here).
41
+ const goVariantClass =
42
+ buttonVariant === "primary_inverse"
43
+ ? "bg-bg-fill-inverse text-text-inverse"
44
+ : buttonVariant === "secondary"
45
+ ? "border border-border-secondary-on-bg-fill bg-bg-fill-secondary text-text"
46
+ : "bg-bg-fill-brand text-text-brand-on-bg-fill";
47
+
48
+ return (
49
+ <div className="flex w-full max-w-[1440px] flex-col items-center justify-center gap-4 py-[10px] lg:flex-row lg:gap-6">
50
+ <span
51
+ className={`label1 w-full whitespace-nowrap text-center text-base lg:hidden ${promptTextClass}`}
52
+ >
53
+ {mobilePrompt}
54
+ </span>
55
+ <span
56
+ className={`label1 hidden lg:block lg:w-auto lg:shrink-0 lg:whitespace-nowrap lg:text-right ${promptTextClass}`}
57
+ >
58
+ {desktopPrompt}
59
+ </span>
60
+ <div className="flex w-full items-stretch gap-2 lg:contents">
61
+ <div className="relative flex min-w-0 flex-1 flex-col items-start justify-center gap-1 lg:max-w-[480px] lg:flex-1">
62
+ <div className="flex h-14 w-full items-center gap-2 self-stretch rounded-input border border-input-border bg-input-bg-surface px-4 focus-within:border-2 focus-within:border-input-border-selected lg:h-[60px]">
63
+ <svg
64
+ xmlns="http://www.w3.org/2000/svg"
65
+ width="20"
66
+ height="20"
67
+ viewBox="0 0 24 24"
68
+ fill="#16a34a"
69
+ aria-hidden="true"
70
+ className="shrink-0"
71
+ >
72
+ <path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5A2.5 2.5 0 1 1 12 6.5a2.5 2.5 0 0 1 0 5z" />
73
+ </svg>
74
+ <input
75
+ type="text"
76
+ value={query}
77
+ onChange={event => setQuery(event.target.value)}
78
+ placeholder={placeholder}
79
+ className="body2 w-full min-w-0 bg-transparent text-text outline-none placeholder:text-input-text-placeholder"
80
+ />
81
+ </div>
82
+ {showResults && (
83
+ <div className="absolute left-0 top-full z-10 mt-1 flex w-full flex-col items-center rounded-lg bg-bg-surface shadow-[1px_1px_16px_0_rgba(0,0,0,0.12),-1px_-1px_16px_0_#FFF]">
84
+ {ADDRESS_SUGGESTIONS.map(address => (
85
+ <button
86
+ key={address}
87
+ type="button"
88
+ className="body2 flex w-full items-center gap-2 px-4 py-3 text-left text-text hover:bg-bg-surface-secondary"
89
+ >
90
+ <svg
91
+ xmlns="http://www.w3.org/2000/svg"
92
+ width="20"
93
+ height="20"
94
+ viewBox="0 0 24 24"
95
+ fill="#757575"
96
+ aria-hidden="true"
97
+ className="shrink-0"
98
+ >
99
+ <path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5A2.5 2.5 0 1 1 12 6.5a2.5 2.5 0 0 1 0 5z" />
100
+ </svg>
101
+ <span className="truncate">{address}</span>
102
+ </button>
103
+ ))}
104
+ </div>
105
+ )}
106
+ </div>
107
+ <button
108
+ type="button"
109
+ className={`flex h-14 w-14 shrink-0 items-center justify-center gap-2 rounded-button text-lg font-black lg:hidden ${goVariantClass}`}
110
+ >
111
+ Go
112
+ </button>
113
+ <div className="hidden lg:block">
114
+ <Button
115
+ showButtonAs="solid"
116
+ buttonVariant={buttonVariant}
117
+ buttonLabel={ctaLabel}
118
+ size={{ base: "large" }}
119
+ />
120
+ </div>
121
+ </div>
122
+ </div>
123
+ );
124
+ };
125
+
126
+ // Story args extend the banner props with the editable mock fields so the
127
+ // address bar text and CTA can be controlled from the Storybook controls
128
+ // instead of being hardcoded.
129
+ type StoryArgs = ComponentProps<typeof AddressInputBanner> & {
130
+ ctaLabel?: string;
131
+ ctaButtonVariant?: "primary_brand" | "primary_inverse" | "secondary";
132
+ mobilePrompt?: string;
133
+ placeholder?: string;
134
+ };
135
+
136
+ const meta: Meta<StoryArgs> = {
137
+ title: "Contentful Blocks/AddressInputBanner",
138
+ component: AddressInputBanner,
139
+ tags: ["autodocs"],
140
+ // Provide a tall, scrollable canvas so the sticky banner's `navHeight` offset
141
+ // is observable — CSS `position: sticky` only applies its `top` value while
142
+ // scrolling within a container taller than the viewport.
143
+ decorators: [
144
+ Story => (
145
+ <div className="min-h-[150vh]">
146
+ <Story />
147
+ <p className="body2 p-6 text-text">
148
+ Scroll to see the banner stick at the top (offset by navHeight).
149
+ </p>
150
+ </div>
151
+ ),
152
+ ],
153
+ parameters: {
154
+ layout: "fullscreen",
155
+ docs: {
156
+ page: DocsPage,
157
+ description: {
158
+ component:
159
+ "A sticky address input banner component that supports multiple themed variants and an optional CTA. It can be shown/hidden dynamically and adjusts positioning based on the navigation height.",
160
+ },
161
+ },
162
+ },
163
+ argTypes: {
164
+ title: {
165
+ control: { type: "text" },
166
+ description: "Title shown as the desktop heading next to the input",
167
+ },
168
+ variant: {
169
+ control: { type: "select" },
170
+ options: ["yellow", "white", "navy", "green"],
171
+ description: "Themed color variant of the banner",
172
+ },
173
+ navHeight: {
174
+ control: { type: "number" },
175
+ description: "Navigation height used to offset the sticky position",
176
+ },
177
+ isVisible: {
178
+ control: { type: "boolean" },
179
+ description: "Whether the banner is rendered",
180
+ },
181
+ ctaLabel: {
182
+ control: { type: "text" },
183
+ description: "CTA button label",
184
+ },
185
+ ctaButtonVariant: {
186
+ control: { type: "select" },
187
+ options: ["primary_brand", "primary_inverse", "secondary"],
188
+ description: "CTA button variant",
189
+ },
190
+ mobilePrompt: {
191
+ control: { type: "text" },
192
+ description: "Prompt text shown on mobile",
193
+ },
194
+ placeholder: {
195
+ control: { type: "text" },
196
+ description: "Address input placeholder text",
197
+ },
198
+ entryName: {
199
+ table: { disable: true },
200
+ },
201
+ cta: {
202
+ table: { disable: true },
203
+ },
204
+ },
205
+ args: {
206
+ title: "To see plans for your address, check availability",
207
+ variant: "yellow",
208
+ isVisible: true,
209
+ navHeight: 0,
210
+ ctaLabel: "Check plans",
211
+ mobilePrompt: "Show me the best offers in my area",
212
+ placeholder: "123 Dale Springs, Apt B, Binsport, NC, 17032",
213
+ },
214
+ render: ({
215
+ title,
216
+ ctaLabel,
217
+ ctaButtonVariant,
218
+ mobilePrompt,
219
+ placeholder,
220
+ renderCheckPlans,
221
+ cta,
222
+ ...args
223
+ }) => {
224
+ // Dark-background variants need light prompt text for contrast.
225
+ const promptTextClass =
226
+ args.variant === "navy" || args.variant === "green"
227
+ ? "text-white"
228
+ : "text-text";
229
+
230
+ // Derive the CTA button variant from the banner variant so it adapts when
231
+ // the variant control changes. An explicit ctaButtonVariant still overrides.
232
+ const effectiveButtonVariant =
233
+ ctaButtonVariant ??
234
+ (args.variant === "yellow" || args.variant === "green"
235
+ ? "primary_inverse"
236
+ : "primary_brand");
237
+
238
+ // Keep the cta's buttonVariant in sync with the effective variant so the
239
+ // ctaButtonVariant control also drives stories that supply their own cta
240
+ // and renderCheckPlans (e.g. WithCta).
241
+ const effectiveCta = cta
242
+ ? { ...cta, buttonVariant: effectiveButtonVariant }
243
+ : cta;
244
+
245
+ return (
246
+ <AddressInputBanner
247
+ {...args}
248
+ cta={effectiveCta}
249
+ title=""
250
+ renderCheckPlans={
251
+ renderCheckPlans ??
252
+ (() => (
253
+ <AddressBar
254
+ buttonVariant={effectiveButtonVariant}
255
+ ctaLabel={ctaLabel}
256
+ desktopPrompt={title}
257
+ mobilePrompt={mobilePrompt}
258
+ placeholder={placeholder}
259
+ promptTextClass={promptTextClass}
260
+ />
261
+ ))
262
+ }
263
+ />
264
+ );
265
+ },
266
+ };
267
+
268
+ export default meta;
269
+ type Story = StoryObj<typeof meta>;
270
+
271
+ // Default banner using all the default entries from meta args
272
+ export const Default: Story = {
273
+ args: {},
274
+ parameters: {
275
+ docs: {
276
+ description: {
277
+ story:
278
+ "Default AddressInputBanner rendered with all the default entries (yellow variant, visible, with the address bar CTA).",
279
+ },
280
+ },
281
+ },
282
+ };
283
+
284
+ // Yellow variant
285
+ export const Yellow: Story = {
286
+ args: {
287
+ variant: "yellow",
288
+ },
289
+ };
290
+
291
+ // White variant
292
+ export const White: Story = {
293
+ args: {
294
+ variant: "white",
295
+ },
296
+ };
297
+
298
+ // Navy variant
299
+ export const Navy: Story = {
300
+ args: {
301
+ variant: "navy",
302
+ },
303
+ };
304
+
305
+ // Green variant
306
+ export const Green: Story = {
307
+ args: {
308
+ variant: "green",
309
+ },
310
+ };
311
+
312
+ // Hidden banner renders nothing
313
+ export const Hidden: Story = {
314
+ args: {
315
+ isVisible: false,
316
+ },
317
+ parameters: {
318
+ docs: {
319
+ description: {
320
+ story:
321
+ "When isVisible is false, the banner renders nothing to the DOM.",
322
+ },
323
+ },
324
+ },
325
+ };
326
+
327
+ // Banner supplied with an explicit cta prop. It falls back to the default
328
+ // address bar render, so it looks consistent with the other variant stories
329
+ // while still exercising the cta overrides.
330
+ export const WithCta: Story = {
331
+ args: {
332
+ cta: {
333
+ buttonLabel: "Check plans",
334
+ buttonVariant: "primary_brand",
335
+ showButtonAs: "solid",
336
+ },
337
+ },
338
+ parameters: {
339
+ docs: {
340
+ description: {
341
+ story:
342
+ "AddressInputBanner rendered with an explicit `cta` prop. It uses the default address bar render, so it looks consistent with the other variant stories while still exercising the `cta` overrides.",
343
+ },
344
+ },
345
+ },
346
+ };
347
+
348
+ // Demonstrates the sticky positioning offset with a non-zero navHeight value.
349
+ // A mock fixed nav bar is layered on top of the shared scrollable canvas so the
350
+ // banner can be seen sticking directly below it once the page scrolls.
351
+ export const CustomNavHeight: Story = {
352
+ args: {
353
+ navHeight: 80,
354
+ },
355
+ decorators: [
356
+ (Story, context) => {
357
+ // Keep the mock nav height/label in sync with the navHeight control.
358
+ const navHeight = (context.args.navHeight as number | undefined) ?? 80;
359
+ return (
360
+ <div className="relative min-h-[150vh]">
361
+ {/* Mock fixed navigation bar that matches the navHeight control */}
362
+ <div
363
+ style={{ height: navHeight }}
364
+ className="fixed left-0 right-0 top-0 z-[90] flex w-full items-center justify-center overflow-hidden whitespace-nowrap bg-bg-fill-inverse text-xs text-text-inverse"
365
+ >
366
+ {`Mock Navigation (${navHeight}px)`}
367
+ </div>
368
+ {/* Spacer so content starts below the fixed nav */}
369
+ <div style={{ height: navHeight }} />
370
+ <Story />
371
+ {/* Tall content in the SAME container as the banner so it has room to
372
+ stick while scrolling. */}
373
+ <div className="flex h-[120vh] items-start justify-center p-6 text-text">
374
+ {`Scroll — the banner stays pinned ${navHeight}px from the top, below the nav.`}
375
+ </div>
376
+ </div>
377
+ );
378
+ },
379
+ ],
380
+ parameters: {
381
+ docs: {
382
+ description: {
383
+ story:
384
+ "Demonstrates the sticky positioning with a non-zero `navHeight` (80px), which offsets the banner's `top` value so it sticks below a fixed navigation bar. Scroll the canvas to see the offset take effect.",
385
+ },
386
+ },
387
+ },
388
+ };
@@ -44,7 +44,7 @@ export const Carousel: React.FC<CarouselProps> = ({
44
44
  </Text>
45
45
  )}
46
46
  </div>
47
- {showSwitch && hasProductCards && (
47
+ {hasProductCards && (
48
48
  <div className="flex flex-col gap-8">
49
49
  {showSwitch && hasProductCards && tabs.length > 1 && (
50
50
  <TabSwitch
@@ -33,7 +33,7 @@ export const EmailInputBlock: React.FC<EmailInputBlockProps> = props => {
33
33
  ctaButtonText,
34
34
  ctaText,
35
35
  caption,
36
- backgroundColor = "green",
36
+ backgroundColor = "white",
37
37
  successFeedback,
38
38
  onSubmit,
39
39
  themeColor,
@@ -48,7 +48,7 @@ export const EmailInputBlock: React.FC<EmailInputBlockProps> = props => {
48
48
  const backgroundColorClasses: Record<string, string> = {
49
49
  green: "bg-bg-fill-brand",
50
50
  white: "bg-bg",
51
- gray90: "bg-[#464646]",
51
+ gray90: "bg-[var(--color-text-secondary)]",
52
52
  };
53
53
 
54
54
  const themeColorClasses: Record<string, string> = {
@@ -59,10 +59,13 @@ export const EmailInputBlock: React.FC<EmailInputBlockProps> = props => {
59
59
  white: "bg-bg",
60
60
  };
61
61
 
62
- const normalizedBg = (backgroundColor as string)?.toLowerCase?.() || "white";
62
+ const normalizedBg =
63
+ ((backgroundColor as string) || "").trim().toLowerCase() || "white";
63
64
  const outerBgClass = backgroundColorClasses[normalizedBg as keyof typeof backgroundColorClasses] || backgroundColorClasses.white;
65
+ const captionTextColorClass = normalizedBg === "white" ? "text-text" : "text-white";
64
66
 
65
- const normalizedTheme = (themeColor as string)?.toLowerCase?.() || "green";
67
+ const normalizedTheme =
68
+ ((themeColor as string) || "").trim().toLowerCase() || "green";
66
69
  const containerBgClass = themeColorClasses[normalizedTheme as keyof typeof themeColorClasses] || themeColorClasses.green;
67
70
 
68
71
  const darkThemeColors = ["green", "blue", "purple"];
@@ -164,7 +167,7 @@ export const EmailInputBlock: React.FC<EmailInputBlockProps> = props => {
164
167
  </div>
165
168
  </div>
166
169
  {caption && (
167
- <div className="mx-auto max-w-[1200px] px-6 md:px-8">{caption}</div>
170
+ <div className={`mx-auto max-w-[1200px] px-6 md:px-8 ${captionTextColorClass}`}>{caption}</div>
168
171
  )}
169
172
  </section>
170
173
  );
@@ -16,6 +16,14 @@ import { MaterialIcon } from "@shared/components/material-icon";
16
16
  import { Text } from "@shared/components/text";
17
17
  import { toDocument } from "@shared/utils/contentful/to-document";
18
18
 
19
+ const isEmptyParagraph = (node: any): boolean =>
20
+ Array.isArray(node?.content) &&
21
+ node.content.every(
22
+ (child: any) =>
23
+ child?.nodeType === "text" &&
24
+ (!child.value || child.value.trim() === "")
25
+ );
26
+
19
27
  const defaultOptions: Options = {
20
28
  renderMark: {
21
29
  [MARKS.BOLD]: text => <strong className="font-black">{text}</strong>,
@@ -24,8 +32,8 @@ const defaultOptions: Options = {
24
32
  [MARKS.CODE]: text => <code>{text}</code>,
25
33
  },
26
34
  renderNode: {
27
- [BLOCKS.PARAGRAPH]: (_node, children) => (
28
- <div className="body3 mb-4">{children}</div>
35
+ [BLOCKS.PARAGRAPH]: (node, children) => (
36
+ <div className="body3 mb-4">{isEmptyParagraph(node) ? "\n" : children}</div>
29
37
  ),
30
38
  [BLOCKS.HEADING_1]: (node, children) => {
31
39
  return (
@@ -156,8 +164,8 @@ export function renderContentfulRichText(
156
164
  ...defaultOptions.renderNode,
157
165
  ...options?.renderNode,
158
166
  // Logic for links based on the isTargetBlank flag
159
- [BLOCKS.PARAGRAPH]: (_node, children) => (
160
- <div className={className}>{children}</div>
167
+ [BLOCKS.PARAGRAPH]: (node, children) => (
168
+ <div className={className}>{isEmptyParagraph(node) ? "\n" : children}</div>
161
169
  ),
162
170
  [INLINES.HYPERLINK]: (node, children) => {
163
171
  const url = (node as any)?.data?.uri as string;