@windstream/react-shared-components 0.2.62 → 0.2.64

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.
@@ -25,19 +25,23 @@ function ProductCardPanel({
25
25
  }) {
26
26
  const itemsExpanded = fields?.items?.items?.[0]?.benefitsExpanded || false;
27
27
  const [desktopExpanded, setDesktopExpanded] = useState(itemsExpanded);
28
- const [mobileExpandedStates, setMobileExpandedStates] = useState<
29
- Record<number, boolean>
30
- >({});
31
28
  const [currentIndex, setCurrentIndex] = useState(0);
32
29
  const [maxIndex, setMaxIndex] = useState(0);
33
30
  const [swipeOffset, setSwipeOffset] = useState(0);
34
31
  const [isSwiping, setIsSwiping] = useState(false);
35
32
  const items = fields?.items?.items || [];
36
- const twoColumn = fields?.twoColumn || false;
33
+ // Prefer the CMS-provided `twoColumn` layout flag.
34
+ // When unset, fall back to the count heuristic: two-column once there are
35
+ // more than 3 cards; 3 or fewer stack vertically.
36
+ const twoColumn = fields?.twoColumn ?? items.length > 3;
37
37
  const isCarousel = items.length > 2;
38
+ // When any card is flagged best-value, every card reserves the banner height
39
+ // so plan name/price stay aligned across a two-column row.
40
+ const hasAnyBadge = items.some((item: any) => item?.highlighted);
38
41
  const showArrows = fields?.showArrows !== false && isCarousel;
39
42
  const cardsRef = useRef<(HTMLDivElement | null)[]>([]);
40
43
  const windowRef = useRef<HTMLDivElement | null>(null);
44
+ const mobileGridRef = useRef<HTMLDivElement | null>(null);
41
45
  const touchStartX = useRef<number>(0);
42
46
 
43
47
  // Determine how many cards fit so we never scroll past the last card
@@ -121,7 +125,9 @@ function ProductCardPanel({
121
125
  setSwipeOffset(0);
122
126
  }, [isSwiping, swipeOffset, prevSlide, nextSlide]);
123
127
 
124
- // Equalize card heights
128
+ // Equalize card heights. Re-runs whenever the panel becomes visible so a tab
129
+ // that was rendered while hidden (display:none) gets its heights recalculated
130
+ // once it is shown. Residential (single panel, always visible) is unaffected.
125
131
  useEffect(() => {
126
132
  if (!isCarousel || !isVisible) return;
127
133
 
@@ -129,7 +135,7 @@ function ProductCardPanel({
129
135
  const cards = cardsRef.current.filter(Boolean) as HTMLDivElement[];
130
136
  if (cards.length === 0) return;
131
137
 
132
- // Reset heights first
138
+ // Reset heights first so each card reports its natural height
133
139
  cards.forEach(card => {
134
140
  card.style.height = "auto";
135
141
  });
@@ -137,21 +143,112 @@ function ProductCardPanel({
137
143
  // Find the tallest card
138
144
  const maxHeight = Math.max(...cards.map(card => card.offsetHeight));
139
145
 
146
+ if (maxHeight <= 0) return;
140
147
  // Set all cards to the max height
141
148
  cards.forEach(card => {
142
149
  card.style.height = `${maxHeight}px`;
143
150
  });
144
151
  };
145
152
 
146
- // Run on mount and when expanded state changes
153
+ // Run immediately for content changes while already visible.
147
154
  equalizeHeights();
148
155
 
149
- // Re-run after a short delay due to dynamic content
156
+ const rafId = requestAnimationFrame(equalizeHeights);
157
+
158
+ // Re-run after a short delay to account for late-loading content (fonts/images)
150
159
  const timeoutId = setTimeout(equalizeHeights, 100);
151
160
 
152
- return () => clearTimeout(timeoutId);
161
+ window.addEventListener("resize", equalizeHeights);
162
+
163
+ return () => {
164
+ cancelAnimationFrame(rafId);
165
+ clearTimeout(timeoutId);
166
+ window.removeEventListener("resize", equalizeHeights);
167
+ };
153
168
  }, [isCarousel, isVisible, desktopExpanded, items.length]);
154
169
 
170
+ // Two-column mobile: match each card's sections to the tallest card in the
171
+ // same row (pair) so section spacing and the CTA align per row. Desktop keeps
172
+ // its fixed section heights and is left untouched.
173
+ useEffect(() => {
174
+ if (!twoColumn || !isVisible) return;
175
+
176
+ const selectors = [
177
+ "[data-pc-header]",
178
+ "[data-pc-description]",
179
+ "[data-pc-gift]",
180
+ ];
181
+
182
+ const clear = () => {
183
+ const grid = mobileGridRef.current;
184
+ if (!grid) return;
185
+ selectors.forEach(sel =>
186
+ grid.querySelectorAll<HTMLElement>(sel).forEach(el => {
187
+ el.style.removeProperty("min-height");
188
+ el.style.removeProperty("display");
189
+ })
190
+ );
191
+ };
192
+
193
+ // `important` is required because Tailwind utilities are generated with
194
+ // !important in this project (tailwind.config `important: true`).
195
+ const equalizePairs = (sel: string, collapseEmpty = false) => {
196
+ const grid = mobileGridRef.current;
197
+ if (!grid) return;
198
+ const els = Array.from(grid.querySelectorAll<HTMLElement>(sel));
199
+ // Reset before measuring so a previous pass doesn't inflate the result.
200
+ els.forEach(el => {
201
+ el.style.removeProperty("min-height");
202
+ el.style.removeProperty("display");
203
+ });
204
+ for (let i = 0; i < els.length; i += 2) {
205
+ const pair = els.slice(i, i + 2);
206
+ // Collapse a row (or lone trailing card) whose gift slots are all empty
207
+ // so no space is reserved when the row has no gift.
208
+ if (
209
+ collapseEmpty &&
210
+ pair.every(el => el.hasAttribute("data-pc-gift-empty"))
211
+ ) {
212
+ pair.forEach(el =>
213
+ el.style.setProperty("display", "none", "important")
214
+ );
215
+ continue;
216
+ }
217
+ if (pair.length < 2) break;
218
+ const max = Math.max(
219
+ ...pair.map(el => el.getBoundingClientRect().height)
220
+ );
221
+ pair.forEach(el =>
222
+ el.style.setProperty("min-height", `${max}px`, "important")
223
+ );
224
+ }
225
+ };
226
+
227
+ const equalize = () => {
228
+ // Only the two-column mobile breakpoint needs JS equalization.
229
+ if (!window.matchMedia("(max-width: 767px)").matches) {
230
+ clear();
231
+ return;
232
+ }
233
+ equalizePairs("[data-pc-header]");
234
+ equalizePairs("[data-pc-description]");
235
+ equalizePairs("[data-pc-gift]", true);
236
+ };
237
+
238
+ const rafId = requestAnimationFrame(equalize);
239
+ const t1 = setTimeout(equalize, 150);
240
+ const t2 = setTimeout(equalize, 500);
241
+ window.addEventListener("resize", equalize);
242
+
243
+ return () => {
244
+ cancelAnimationFrame(rafId);
245
+ clearTimeout(t1);
246
+ clearTimeout(t2);
247
+ window.removeEventListener("resize", equalize);
248
+ clear();
249
+ };
250
+ }, [twoColumn, isVisible, desktopExpanded, items.length]);
251
+
155
252
  if (!items.length && !fields?.title) {
156
253
  return null;
157
254
  }
@@ -174,20 +271,12 @@ function ProductCardPanel({
174
271
  badgeIcon: item.innerBadgeIcon?.url || "",
175
272
  };
176
273
 
177
- // Mobile: individual card state, Desktop: shared state
178
- const isExpanded = isMobileView
179
- ? mobileExpandedStates[globalIndex] || false
180
- : desktopExpanded;
274
+ // Shared expand state so toggling "More details" on one card opens the
275
+ // section on the adjacent cards too (matches the residential behavior).
276
+ const isExpanded = desktopExpanded;
181
277
 
182
278
  const handleToggle = () => {
183
- if (isMobileView) {
184
- setMobileExpandedStates(prev => ({
185
- ...prev,
186
- [globalIndex]: !prev[globalIndex],
187
- }));
188
- } else {
189
- setDesktopExpanded(!desktopExpanded);
190
- }
279
+ setDesktopExpanded(prev => !prev);
191
280
  };
192
281
 
193
282
  const handleCtaClick = () => {
@@ -234,6 +323,8 @@ function ProductCardPanel({
234
323
  renderCheckPlans={renderCheckPlans}
235
324
  cta={item.cta}
236
325
  priceSuffixBottom={item?.priceSuffixBottom}
326
+ hasAnyBadge={hasAnyBadge}
327
+ twoColumn={twoColumn}
237
328
  />
238
329
  </div>
239
330
  );
@@ -241,6 +332,7 @@ function ProductCardPanel({
241
332
 
242
333
  return !isCarousel ? (
243
334
  <div
335
+ ref={mobileGridRef}
244
336
  className={cx(
245
337
  "w-full max-w-[1280px] gap-4",
246
338
  twoColumn
@@ -254,6 +346,7 @@ function ProductCardPanel({
254
346
  <>
255
347
  {/* Mobile/Tablet View: Vertical Stack (or two-column grid) */}
256
348
  <div
349
+ ref={mobileGridRef}
257
350
  className={cx(
258
351
  "md:hidden",
259
352
  twoColumn ? "grid grid-cols-2 gap-4" : "flex flex-col gap-6"
@@ -164,7 +164,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
164
164
  </div>
165
165
  )}
166
166
  </div>
167
- <aside className="flex w-full flex-1 shrink-0 items-center justify-center lg:w-auto">
167
+ <div className="flex w-full flex-1 shrink-0 items-center justify-center lg:w-auto">
168
168
  {/* Media Section: only when no Video Link is attached at all */}
169
169
  {image && !hasVideoLink && (
170
170
  <div
@@ -203,7 +203,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
203
203
  isAboveTheFold={isAboveTheFold}
204
204
  onVideoEvent={onVideoEvent}
205
205
  />
206
- </aside>
206
+ </div>
207
207
  </div>
208
208
  </div>
209
209
 
@@ -276,6 +276,18 @@ describe("renderContentfulRichText", () => {
276
276
  const a = container.querySelector("a")!;
277
277
  expect(a).toHaveClass("custom-link");
278
278
  });
279
+
280
+ it("uses a separate class for links inside list items", () => {
281
+ const list = makeList(false, ["List link"]);
282
+ list.content[0].content = [makeHyperlink("/list-link", "List link")];
283
+ const doc = makeDoc(makeHyperlink("/paragraph-link", "Paragraph link"), list);
284
+
285
+ const { container } = render(<>{renderContentfulRichText(doc)}</>);
286
+ const links = container.querySelectorAll("a");
287
+
288
+ expect(links[0]).toHaveClass("body1", "font-bold");
289
+ expect(links[1]).toHaveClass("body2");
290
+ });
279
291
  });
280
292
 
281
293
  describe("Custom options merge", () => {
@@ -267,10 +267,23 @@ export function renderContentfulRichText(
267
267
  className: string = "body1",
268
268
  linkClassName: string = "body1 font-bold",
269
269
  options?: Options,
270
- links?: any
270
+ links?: any,
271
+ listLinkClassName: string = "body2"
271
272
  ): ReactNode | null {
272
273
  if (!doc || !Array.isArray(doc.content)) return null;
273
274
 
275
+ const listItemHyperlinks = new WeakSet<object>();
276
+ const collectListItemHyperlinks = (node: any, isInListItem = false) => {
277
+ const isListItem = isInListItem || node?.nodeType === BLOCKS.LIST_ITEM;
278
+ if (isListItem && node?.nodeType === INLINES.HYPERLINK) {
279
+ listItemHyperlinks.add(node);
280
+ }
281
+ node?.content?.forEach((child: any) =>
282
+ collectListItemHyperlinks(child, isListItem)
283
+ );
284
+ };
285
+ collectListItemHyperlinks(doc);
286
+
274
287
  const defaultOptions = getDefaultOptions(links);
275
288
  const merged: Options = {
276
289
  ...defaultOptions,
@@ -301,7 +314,9 @@ export function renderContentfulRichText(
301
314
  target={shouldOpenBlank ? "_blank" : undefined}
302
315
  rel={shouldOpenBlank ? "noopener" : undefined}
303
316
  variant="default"
304
- className={linkClassName}
317
+ className={
318
+ listItemHyperlinks.has(node) ? listLinkClassName : linkClassName
319
+ }
305
320
  >
306
321
  {children}
307
322
  </Link>