@arc-ui/community-components 3.15.0 → 3.17.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.
Files changed (67) hide show
  1. package/.turbo/turbo-build.log +14 -14
  2. package/CHANGELOG.md +27 -0
  3. package/lib/ArticleCarousel/ArticleCarousel.cjs +1 -1
  4. package/lib/ArticleCarousel/ArticleCarousel.mjs +1 -1
  5. package/lib/ComparisonTable/ComparisonTable.cjs +317 -0
  6. package/lib/ComparisonTable/ComparisonTable.mjs +310 -0
  7. package/lib/ComparisonTable/styles.css +1 -0
  8. package/lib/CopyLead/CopyLead.cjs +1 -1
  9. package/lib/CopyLead/CopyLead.mjs +1 -1
  10. package/lib/FeaturePost/FeaturePost.cjs +13 -12
  11. package/lib/FeaturePost/FeaturePost.mjs +13 -12
  12. package/lib/FeaturePost/styles.css +1 -1
  13. package/lib/Highlights/Highlights.cjs +7 -7
  14. package/lib/Highlights/Highlights.mjs +7 -7
  15. package/lib/ProductCard/ProductCard.cjs +13 -35
  16. package/lib/ProductCard/ProductCard.mjs +12 -34
  17. package/lib/ProductNavigation/ProductNavigation.cjs +1 -1
  18. package/lib/ProductNavigation/ProductNavigation.mjs +1 -1
  19. package/lib/PromoListing/PromoListing.cjs +1 -1
  20. package/lib/PromoListing/PromoListing.mjs +1 -1
  21. package/lib/Summary/Summary.cjs +1 -1
  22. package/lib/Summary/Summary.mjs +1 -1
  23. package/lib/_shared/cjs/ProductCardPriceIncrease-BP39clav.cjs +38 -0
  24. package/lib/_shared/cjs/{index.es-C-y6aRIY.cjs → index.es-oEhoU2Bd.cjs} +1 -1
  25. package/lib/_shared/esm/ProductCardPriceIncrease-CJyG2wv-.mjs +35 -0
  26. package/lib/_shared/esm/{index.es-YxZ1vAot.mjs → index.es-DujqqZXe.mjs} +1 -1
  27. package/lib/index.cjs +508 -200
  28. package/lib/index.cjs.map +1 -1
  29. package/lib/index.d.cts +213 -22
  30. package/lib/index.d.mts +213 -22
  31. package/lib/index.mjs +503 -201
  32. package/lib/index.mjs.map +1 -1
  33. package/lib/styles.css +1 -1
  34. package/package.json +16 -16
  35. package/src/components/ComparisonTable/ComparisonTable.module.css +503 -0
  36. package/src/components/ComparisonTable/ComparisonTable.tsx +104 -0
  37. package/src/components/ComparisonTable/components/Cell/Cell.tsx +44 -0
  38. package/src/components/ComparisonTable/components/ControlCell/ControlCell.tsx +21 -0
  39. package/src/components/ComparisonTable/components/DesktopRow/DesktopRow.tsx +59 -0
  40. package/src/components/ComparisonTable/components/DesktopTable/DesktopTable.tsx +150 -0
  41. package/src/components/ComparisonTable/components/FeatureCell/FeatureCell.module.css +56 -0
  42. package/src/components/ComparisonTable/components/FeatureCell/FeatureCell.tsx +96 -0
  43. package/src/components/ComparisonTable/components/HeaderCell/HeaderCell.tsx +59 -0
  44. package/src/components/ComparisonTable/components/MobileRow/MobileRow.tsx +77 -0
  45. package/src/components/ComparisonTable/components/MobileTable/MobileTable.tsx +103 -0
  46. package/src/components/ComparisonTable/components/Section/Section.tsx +75 -0
  47. package/src/components/ComparisonTable/components/SectionToggle/SectionToggle.tsx +63 -0
  48. package/src/components/ComparisonTable/components/SubHeaderCell/SubHeaderCell.tsx +62 -0
  49. package/src/components/ComparisonTable/index.ts +22 -0
  50. package/src/components/ComparisonTable/types/comparison-column.ts +22 -0
  51. package/src/components/ComparisonTable/types/comparison-row.ts +23 -0
  52. package/src/components/ComparisonTable/types/comparison-section.ts +39 -0
  53. package/src/components/ComparisonTable/types/comparison-sub-row.ts +8 -0
  54. package/src/components/ComparisonTable/utils/group-rows.ts +69 -0
  55. package/src/components/ComparisonTable/utils/use-section-state.ts +45 -0
  56. package/src/components/FeaturePost/FeaturePost.module.css +24 -2
  57. package/src/components/FeaturePost/FeaturePost.tsx +26 -25
  58. package/src/components/FeaturePost/types/feature-post-cta-footer.ts +4 -3
  59. package/src/components/Highlights/Highlights.tsx +5 -32
  60. package/src/components/ProductCard/ProductCard.tsx +14 -14
  61. package/src/components/ProductCard/ProductCardPrice/ProductCardPrice.tsx +11 -3
  62. package/src/components/ProductCard/ProductCardPriceIncrease/ProductCardPriceIncrease.module.css +1 -2
  63. package/src/components/ProductCard/ProductCardPriceIncrease/ProductCardPriceIncrease.tsx +14 -3
  64. package/src/components/ProductCardGrid/ProductCardGrid.tsx +8 -8
  65. package/src/components/ProductCardGrid/components/CardGrid/CardGrid.tsx +6 -6
  66. package/src/components/index.ts +1 -0
  67. package/versions.json +1 -1
@@ -0,0 +1,59 @@
1
+ import React from "react";
2
+ import classNames from "classnames";
3
+
4
+ import { Text } from "@arc-ui/components/Text";
5
+
6
+ import { FeatureCell } from "../FeatureCell/FeatureCell";
7
+ import styles from "../../ComparisonTable.module.css";
8
+ import { type ComparisonRow } from "../../types/comparison-row";
9
+
10
+ /**
11
+ * The same plans are highlighted in both layouts: plan index 0, 2, 4, ... (the
12
+ * 1st, 3rd, 5th plan). On desktop the leading feature column shifts these into
13
+ * the 2nd, 4th, ... visual grid columns, but it's the same plan as on mobile.
14
+ */
15
+ const isPlanHighlighted = (planIndex: number) => planIndex % 2 === 0;
16
+
17
+ export const DesktopRow: React.FC<DesktopRowProps> = ({
18
+ row,
19
+ isLastRow = false,
20
+ }) => (
21
+ <div
22
+ role="row"
23
+ className={classNames(styles.desktopRow, {
24
+ [styles.isLastRow]: isLastRow,
25
+ })}
26
+ >
27
+ <div role="rowheader" className={styles.desktopFeatureCell}>
28
+ <FeatureCell
29
+ icon={row.feature.icon}
30
+ title={row.feature.title}
31
+ description={row.feature.description}
32
+ popoverContent={row.feature.popoverContent}
33
+ />
34
+ </div>
35
+ {row.cells.map((cell, cellIndex) => (
36
+ <div
37
+ key={`desktop-cell-${cellIndex}`}
38
+ role="cell"
39
+ className={classNames(styles.cell, {
40
+ [styles.highlighted]: isPlanHighlighted(cellIndex),
41
+ [styles.highlightedBottom]: isPlanHighlighted(cellIndex) && isLastRow,
42
+ })}
43
+ >
44
+ <Text>{cell}</Text>
45
+ </div>
46
+ ))}
47
+ </div>
48
+ );
49
+
50
+ export interface DesktopRowProps {
51
+ /**
52
+ * The row to render as a single desktop row: feature in the left-hand column, a value per plan column.
53
+ */
54
+ row: ComparisonRow;
55
+ /**
56
+ * Whether this is the last row of the whole table, so its highlighted cells round their bottom corners.
57
+ */
58
+ isLastRow?: boolean;
59
+ }
@@ -0,0 +1,150 @@
1
+ import React from "react";
2
+ import classNames from "classnames";
3
+
4
+ import { Heading } from "@arc-ui/components/Heading";
5
+ import { Surface } from "@arc-ui/components/Surface";
6
+ import { Text } from "@arc-ui/components/Text";
7
+ import { VisuallyHidden } from "@arc-ui/components/VisuallyHidden";
8
+
9
+ import { DesktopRow } from "../DesktopRow/DesktopRow";
10
+ import { Section } from "../Section/Section";
11
+ import { SubHeaderCell } from "../SubHeaderCell/SubHeaderCell";
12
+ import styles from "../../ComparisonTable.module.css";
13
+ import { type ComparisonColumn } from "../../types/comparison-column";
14
+ import { type ComparisonRow } from "../../types/comparison-row";
15
+ import { type ResolvedSection } from "../../types/comparison-section";
16
+
17
+ /**
18
+ * The same plans are highlighted in both layouts: plan index 0, 2, 4, ... (the
19
+ * 1st, 3rd, 5th plan). On desktop the leading feature column shifts these into
20
+ * the 2nd, 4th, ... visual grid columns, but it's the same plan as on mobile.
21
+ */
22
+ const isPlanHighlighted = (planIndex: number) => planIndex % 2 === 0;
23
+
24
+ export const DesktopTable: React.FC<DesktopTableProps> = ({
25
+ columns,
26
+ rows,
27
+ sections,
28
+ controls,
29
+ hasStickyHeader,
30
+ className,
31
+ }) => {
32
+ const hasSubheader = columns.some((column) => column.subheader);
33
+
34
+ return (
35
+ <div
36
+ role="table"
37
+ className={classNames(styles.desktopTable, className, {
38
+ [styles.desktopTableSticky]: hasStickyHeader,
39
+ })}
40
+ style={{ "--comparison-columns": columns.length } as React.CSSProperties}
41
+ >
42
+ {/* Header row: feature corner + plan headers */}
43
+ <div
44
+ role="row"
45
+ className={classNames(styles.desktopHeaderRow, {
46
+ [styles.stickyHeader]: hasStickyHeader,
47
+ })}
48
+ >
49
+ <div role="columnheader" className={styles.desktopFeatureHeader}>
50
+ <VisuallyHidden>Features</VisuallyHidden>
51
+ </div>
52
+ {columns.map((column, index) => (
53
+ <div
54
+ key={column.id}
55
+ role="columnheader"
56
+ className={classNames(styles.desktopHeaderCell, {
57
+ [styles.highlighted]: isPlanHighlighted(index),
58
+ [styles.highlightedTop]: isPlanHighlighted(index),
59
+ })}
60
+ >
61
+ {column.promoted && (
62
+ <div className={styles.headerCellPromoted}>
63
+ <Surface
64
+ background={"dark-brand-01"}
65
+ paddingVertical="16"
66
+ paddingHorizontal="24"
67
+ >
68
+ <VisuallyHidden>Promotional offer. </VisuallyHidden>
69
+ <Text size="s">{column.promoted}</Text>
70
+ </Surface>
71
+ </div>
72
+ )}
73
+ <Heading size="m">{column.label}</Heading>
74
+ </div>
75
+ ))}
76
+ </div>
77
+
78
+ {/* Controls (in the feature column) + sub-header cells, on one row */}
79
+ {(controls || hasSubheader) && (
80
+ <div role="row" className={styles.desktopSubHeaderRow}>
81
+ <div
82
+ className={styles.desktopControlsCell}
83
+ aria-label="comparison table controls"
84
+ >
85
+ {controls}
86
+ </div>
87
+ {columns.map((column, index) => (
88
+ <SubHeaderCell
89
+ key={column.id}
90
+ columnIndex={isPlanHighlighted(index) ? 0 : 1}
91
+ {...(column.subheader ?? { price: { value: "" } })}
92
+ />
93
+ ))}
94
+ </div>
95
+ )}
96
+
97
+ {/* Feature rows: feature label column + value cells */}
98
+ {rows.map((row, rowIndex) => (
99
+ <DesktopRow
100
+ key={`desktop-row-${rowIndex}`}
101
+ row={row}
102
+ isLastRow={sections.length === 0 && rowIndex === rows.length - 1}
103
+ />
104
+ ))}
105
+
106
+ {/* Collapsible sections */}
107
+ {sections.map((section, index) => (
108
+ <Section
109
+ key={section.id}
110
+ section={section}
111
+ isLastSection={index === sections.length - 1}
112
+ renderRow={(row, rowIndex, isLast) => (
113
+ <DesktopRow
114
+ key={`desktop-section-row-${rowIndex}`}
115
+ row={row}
116
+ isLastRow={isLast}
117
+ />
118
+ )}
119
+ />
120
+ ))}
121
+ </div>
122
+ );
123
+ };
124
+
125
+ export interface DesktopTableProps {
126
+ /**
127
+ * Column definitions driving the plan header cells and the number of value cells per row.
128
+ */
129
+ columns: ComparisonColumn[];
130
+ /**
131
+ * Unsectioned rows, rendered before sections; each renders as a single row with the feature in the left-hand column and a value per plan column.
132
+ */
133
+ rows: ComparisonRow[];
134
+ /**
135
+ * Resolved collapsible sections (empty ones already removed), rendered after the unsectioned rows.
136
+ */
137
+ sections: ResolvedSection[];
138
+ /**
139
+ * Optional controls rendered in the left-hand feature column, alongside the sub-header row.
140
+ */
141
+ controls?: React.ReactNode;
142
+ /**
143
+ * When `true`, the header row sticks to the top of the scroll container as the body scrolls.
144
+ */
145
+ hasStickyHeader?: boolean;
146
+ /**
147
+ * Additional class applied to the root, used by the parent to toggle visibility per breakpoint.
148
+ */
149
+ className?: string;
150
+ }
@@ -0,0 +1,56 @@
1
+ @import url("@arc-ui/tokens-arc/dist/custom-media.css");
2
+
3
+ .packageCell {
4
+ position: relative;
5
+ display: flex;
6
+ flex-direction: row;
7
+ max-width: 300px;
8
+ padding: 16px 24px 16px 0;
9
+ }
10
+
11
+ .packageCellPopoverDesktop {
12
+ display: none;
13
+ }
14
+
15
+ .packageCellTitle {
16
+ display: flex;
17
+ flex-direction: column;
18
+ column-gap: var(--sem-space-gap-40);
19
+ }
20
+
21
+ .packageCellHeading {
22
+ display: flex;
23
+ flex-direction: row;
24
+ gap: 8px;
25
+ align-items: center;
26
+ }
27
+
28
+ @media (--arc-custom-media-min-width-l-px) {
29
+ .packageCellTitle {
30
+ flex-direction: row;
31
+ }
32
+
33
+ .packageCellHeading {
34
+ padding-right: 24px;
35
+ }
36
+ }
37
+
38
+ @media (--arc-custom-media-min-width-s-px) {
39
+ .packageCellPopoverDesktop {
40
+ display: block;
41
+ position: absolute;
42
+ top: 20px;
43
+ right: 24px;
44
+ }
45
+
46
+ .packageCellPopoverMobile {
47
+ display: none;
48
+ }
49
+ }
50
+
51
+ .packageCellPopover {
52
+ display: flex;
53
+ height: 100%;
54
+ justify-content: flex-start;
55
+ padding-top: var(--sem-space-gap-40);
56
+ }
@@ -0,0 +1,96 @@
1
+ import React from "react";
2
+
3
+ import { Icon } from "@arc-ui/components/Icon";
4
+ import { Heading } from "@arc-ui/components/Heading";
5
+ import { Text } from "@arc-ui/components/Text";
6
+ import { BtIconInfo } from "@arc-ui/icons/BtIconInfo";
7
+ import { Popover } from "@arc-ui/components/Popover";
8
+
9
+ import styles from "./FeatureCell.module.css";
10
+
11
+ export const FeatureCell: React.FC<FeatureCellProps> = ({
12
+ icon,
13
+ title,
14
+ description,
15
+ popoverContent,
16
+ }) => (
17
+ <div className={styles.packageCell}>
18
+ <div className={styles.packageCellTitle}>
19
+ <div style={{ width: 32 }}>
20
+ <Icon icon={icon} size={32} color="brand" />
21
+ </div>
22
+ <div className={styles.packageCellText}>
23
+ <div className={styles.packageCellHeading}>
24
+ <Heading size="xs" isWordWrap>
25
+ {title}
26
+ </Heading>
27
+ {popoverContent && (
28
+ <div className={styles.packageCellPopoverMobile}>
29
+ <Popover
30
+ arrow
31
+ background="white"
32
+ constrainToViewport
33
+ content={<Text>{popoverContent}</Text>}
34
+ maxWidth={300}
35
+ side="top"
36
+ >
37
+ <Icon
38
+ icon={BtIconInfo}
39
+ size={16}
40
+ color="brand"
41
+ label={`More information about ${title}`}
42
+ />
43
+ </Popover>
44
+ </div>
45
+ )}{" "}
46
+ </div>
47
+ {description && (
48
+ <Text size="xs" tone="muted">
49
+ {description}
50
+ </Text>
51
+ )}
52
+ </div>
53
+ </div>
54
+ {popoverContent && (
55
+ <div className={styles.packageCellPopoverDesktop}>
56
+ <Popover
57
+ arrow
58
+ background="white"
59
+ constrainToViewport
60
+ content={<Text>{popoverContent}</Text>}
61
+ side="bottom"
62
+ align="start"
63
+ >
64
+ <Icon
65
+ icon={BtIconInfo}
66
+ size={16}
67
+ color="brand"
68
+ label={`More information about ${title}`}
69
+ />
70
+ </Popover>
71
+ </div>
72
+ )}
73
+ </div>
74
+ );
75
+
76
+ export interface FeatureCellProps {
77
+ /**
78
+ * Icon shown alongside the package name.
79
+ */
80
+ icon: string;
81
+
82
+ /**
83
+ * Package / plan title.
84
+ */
85
+ title: string;
86
+
87
+ /**
88
+ * Optional supporting copy shown beneath the title.
89
+ */
90
+ description?: string;
91
+
92
+ /**
93
+ * Optional popover content shown when the user interacts with the info icon.
94
+ */
95
+ popoverContent?: string | React.ReactNode;
96
+ }
@@ -0,0 +1,59 @@
1
+ import React from "react";
2
+ import classNames from "classnames";
3
+
4
+ import { filterAttrs } from "@arc-ui/community-utils/filter-attrs";
5
+ import { Heading } from "@arc-ui/components/Heading";
6
+ import { Surface } from "@arc-ui/components/Surface";
7
+ import { Text } from "@arc-ui/components/Text";
8
+ import { VisuallyHidden } from "@arc-ui/components/VisuallyHidden";
9
+
10
+ import styles from "../../ComparisonTable.module.css";
11
+
12
+ export const HeaderCell: React.FC<HeaderCellProps> = ({
13
+ children,
14
+ columnIndex = 0,
15
+ promoted,
16
+ ...props
17
+ }) => (
18
+ <div
19
+ role="columnheader"
20
+ className={classNames(styles.headerCell, {
21
+ [styles.highlighted]: columnIndex % 2 === 0,
22
+ [styles.highlightedTop]: columnIndex % 2 === 0,
23
+ })}
24
+ {...filterAttrs(props)}
25
+ >
26
+ {promoted && (
27
+ <div className={styles.headerCellPromoted}>
28
+ <Surface
29
+ background={"dark-brand-01"}
30
+ paddingVertical="16"
31
+ paddingHorizontal="24"
32
+ >
33
+ <VisuallyHidden>Promotional offer. </VisuallyHidden>
34
+ <Text size="s">{promoted}</Text>
35
+ </Surface>
36
+ </div>
37
+ )}
38
+ {children != null && (
39
+ <Heading size="m" isWordWrap>
40
+ {children}
41
+ </Heading>
42
+ )}
43
+ </div>
44
+ );
45
+
46
+ export interface HeaderCellProps {
47
+ /**
48
+ * Content rendered inside the column header cell (typically a column label).
49
+ */
50
+ children?: string;
51
+ /**
52
+ * Zero-based index of the column this header cell belongs to. Used to highlight every other column, starting with the first.
53
+ */
54
+ columnIndex?: number;
55
+ /**
56
+ * Optional promotional text displayed in a branded banner above the column label.
57
+ */
58
+ promoted?: string;
59
+ }
@@ -0,0 +1,77 @@
1
+ import React from "react";
2
+ import classNames from "classnames";
3
+
4
+ import { filterAttrs } from "@arc-ui/community-utils/filter-attrs";
5
+
6
+ import { Cell } from "../Cell/Cell";
7
+ import { FeatureCell } from "../FeatureCell/FeatureCell";
8
+ import styles from "../../ComparisonTable.module.css";
9
+ import { type ComparisonRow } from "../../types/comparison-row";
10
+
11
+ /**
12
+ * Renders a single content row of cells for the mobile layout.
13
+ */
14
+ const renderRow = (
15
+ cells: React.ReactNode[],
16
+ key: string,
17
+ isLastRow: boolean,
18
+ ) => (
19
+ <div key={key} className={styles.row}>
20
+ {cells.map((cell, index) => (
21
+ <Cell key={`cell-${index}`} columnIndex={index} isLastRow={isLastRow}>
22
+ {cell}
23
+ </Cell>
24
+ ))}
25
+ </div>
26
+ );
27
+
28
+ export const MobileRow: React.FC<MobileRowProps> = ({
29
+ row,
30
+ isLastGroup = false,
31
+ ...props
32
+ }) => {
33
+ const subRows = row.subRows ?? [];
34
+ const hasSubRows = subRows.length > 0;
35
+
36
+ return (
37
+ <div
38
+ role="row"
39
+ className={classNames(styles.rowGroup, {
40
+ [styles.isLastRow]: isLastGroup,
41
+ })}
42
+ {...filterAttrs(props)}
43
+ >
44
+ <div className={styles.featureRow}>
45
+ <div role="rowheader" className={styles.featureRowHeader}>
46
+ <div className={styles.featureRowHeaderContent}>
47
+ <FeatureCell
48
+ icon={row.feature.icon}
49
+ title={row.feature.title}
50
+ description={row.feature.description}
51
+ popoverContent={row.feature.popoverContent}
52
+ />
53
+ </div>
54
+ </div>
55
+ </div>
56
+ {renderRow(row.cells, "primary-row", isLastGroup && !hasSubRows)}
57
+ {subRows.map((subRow, index) =>
58
+ renderRow(
59
+ subRow.cells,
60
+ `row-${index}`,
61
+ isLastGroup && index === subRows.length - 1,
62
+ ),
63
+ )}
64
+ </div>
65
+ );
66
+ };
67
+
68
+ export interface MobileRowProps {
69
+ /**
70
+ * The row to render: a full-width feature header followed by its content row(s).
71
+ */
72
+ row: ComparisonRow;
73
+ /**
74
+ * Whether this is the last row in the table. Used so the final content row can round its highlighted cells.
75
+ */
76
+ isLastGroup?: boolean;
77
+ }
@@ -0,0 +1,103 @@
1
+ import React from "react";
2
+ import classNames from "classnames";
3
+
4
+ import { HeaderCell } from "../HeaderCell/HeaderCell";
5
+ import { ControlCell } from "../ControlCell/ControlCell";
6
+ import { MobileRow } from "../MobileRow/MobileRow";
7
+ import { Section } from "../Section/Section";
8
+ import { SubHeaderCell } from "../SubHeaderCell/SubHeaderCell";
9
+ import styles from "../../ComparisonTable.module.css";
10
+ import { type ComparisonColumn } from "../../types/comparison-column";
11
+ import { type ComparisonRow } from "../../types/comparison-row";
12
+ import { type ResolvedSection } from "../../types/comparison-section";
13
+
14
+ export const MobileTable: React.FC<MobileTableProps> = ({
15
+ columns,
16
+ rows,
17
+ sections,
18
+ controls,
19
+ className,
20
+ }) => (
21
+ <div
22
+ role="table"
23
+ className={classNames(styles.table, className)}
24
+ style={{ "--comparison-columns": columns.length } as React.CSSProperties}
25
+ >
26
+ <div role="row" className={styles.headerRow}>
27
+ {columns.map((column, index) => (
28
+ <HeaderCell
29
+ key={column.id}
30
+ columnIndex={index}
31
+ promoted={column.promoted}
32
+ >
33
+ {column.label}
34
+ </HeaderCell>
35
+ ))}
36
+ </div>
37
+ {controls && (
38
+ <div
39
+ role="row"
40
+ className={styles.controlRow}
41
+ aria-label="comparison table controls"
42
+ >
43
+ <ControlCell>{controls}</ControlCell>
44
+ </div>
45
+ )}
46
+ {columns.some((column) => column.subheader) && (
47
+ <div role="row" className={styles.subHeaderRow}>
48
+ {columns.map((column, index) => (
49
+ <SubHeaderCell
50
+ key={column.id}
51
+ columnIndex={index}
52
+ {...(column.subheader ?? { price: { value: "" } })}
53
+ />
54
+ ))}
55
+ </div>
56
+ )}
57
+
58
+ {rows.map((row, index) => (
59
+ <MobileRow
60
+ key={`row-group-${index}`}
61
+ row={row}
62
+ isLastGroup={sections.length === 0 && index === rows.length - 1}
63
+ />
64
+ ))}
65
+ {sections.map((section, index) => (
66
+ <Section
67
+ key={section.id}
68
+ section={section}
69
+ isLastSection={index === sections.length - 1}
70
+ renderRow={(row, rowIndex, isLast) => (
71
+ <MobileRow
72
+ key={`section-group-${rowIndex}`}
73
+ row={row}
74
+ isLastGroup={isLast}
75
+ />
76
+ )}
77
+ />
78
+ ))}
79
+ </div>
80
+ );
81
+
82
+ export interface MobileTableProps {
83
+ /**
84
+ * Column definitions driving the header cells and the number of content cells per row.
85
+ */
86
+ columns: ComparisonColumn[];
87
+ /**
88
+ * Unsectioned rows, rendered before sections; each row has a full-width feature header followed by its content row(s).
89
+ */
90
+ rows: ComparisonRow[];
91
+ /**
92
+ * Resolved collapsible sections (empty ones already removed), rendered after the unsectioned rows.
93
+ */
94
+ sections: ResolvedSection[];
95
+ /**
96
+ * Optional controls rendered in a single full-width cell beneath the header, spanning all columns.
97
+ */
98
+ controls?: React.ReactNode;
99
+ /**
100
+ * Additional class applied to the root, used by the parent to toggle visibility per breakpoint.
101
+ */
102
+ className?: string;
103
+ }
@@ -0,0 +1,75 @@
1
+ import React, { useId } from "react";
2
+ import classNames from "classnames";
3
+
4
+ import { SectionToggle } from "../SectionToggle/SectionToggle";
5
+ import styles from "../../ComparisonTable.module.css";
6
+ import { type ResolvedSection } from "../../types/comparison-section";
7
+ import { type ComparisonRow } from "../../types/comparison-row";
8
+ import { useSectionState } from "../../utils/use-section-state";
9
+
10
+ export const Section: React.FC<SectionProps> = ({
11
+ section,
12
+ isLastSection,
13
+ renderRow,
14
+ }) => {
15
+ const regionId = useId();
16
+ const { isOpen, toggle } = useSectionState({
17
+ defaultOpen: section.defaultOpen,
18
+ open: section.open,
19
+ onOpenChange: section.onOpenChange,
20
+ });
21
+
22
+ const lastRowIndex = section.rows.length - 1;
23
+
24
+ return (
25
+ <div role="rowgroup">
26
+ <div
27
+ role="row"
28
+ className={classNames(styles.sectionToggleRow, {
29
+ [styles.isLastRow]: isLastSection === true && !isOpen,
30
+ })}
31
+ >
32
+ <SectionToggle
33
+ heading={section.heading}
34
+ headingLevel={section.headingLevel}
35
+ isOpen={isOpen}
36
+ onToggle={toggle}
37
+ controlsId={regionId}
38
+ />
39
+ </div>
40
+ {isOpen && (
41
+ <div id={regionId} className={styles.sectionContent}>
42
+ {section.rows.map((row, index) =>
43
+ renderRow(
44
+ row,
45
+ index,
46
+ isLastSection === true && index === lastRowIndex,
47
+ ),
48
+ )}
49
+ </div>
50
+ )}
51
+ </div>
52
+ );
53
+ };
54
+
55
+ export interface SectionProps {
56
+ /**
57
+ * The section to render: a toggle row and its collapsible rows.
58
+ */
59
+ section: ResolvedSection;
60
+ /**
61
+ * Whether this is the last section in the table. When open, its final row
62
+ * rounds its highlighted cells; when closed, its toggle row is the table's
63
+ * last row and drops its bottom divider.
64
+ */
65
+ isLastSection?: boolean;
66
+ /**
67
+ * Render callback for each row in the section. The caller decides the
68
+ * layout-specific row component (e.g. `MobileRow` or `DesktopRow`).
69
+ */
70
+ renderRow: (
71
+ row: ComparisonRow,
72
+ index: number,
73
+ isLast: boolean,
74
+ ) => React.ReactNode;
75
+ }