@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,503 @@
1
+ /* stylelint-disable no-descending-specificity */
2
+
3
+ @import url("@arc-ui/tokens-arc/dist/custom-media.css");
4
+
5
+ .wrapper {
6
+ position: relative;
7
+ }
8
+
9
+ .wrapperBordered {
10
+ border-radius: 64px;
11
+ padding: 32px 0 0;
12
+
13
+ /*
14
+ * `clip` keeps the rounded-corner clipping (like `hidden`) but does not
15
+ * establish a scroll container, so a nested table with `overflow-x: auto`
16
+ * can still scroll horizontally within the border.
17
+ */
18
+ overflow: clip;
19
+ }
20
+
21
+ .shadowOverlay {
22
+ position: absolute;
23
+ inset: 0;
24
+ z-index: 2;
25
+ border-radius: inherit;
26
+ box-shadow: inset 0 0 16px 0 rgba(0, 0, 0, 25%);
27
+ pointer-events: none;
28
+ }
29
+
30
+ .wrapperBordered .table {
31
+ padding: 0 32px 32px;
32
+ }
33
+
34
+ .wrapperBordered .desktopTable {
35
+ padding: 0 32px 32px;
36
+ }
37
+
38
+ .table {
39
+ /*
40
+ * Below m: plan columns are a fixed width (what each would be at the l
41
+ * breakpoint on desktop). The table scrolls horizontally when they overflow.
42
+ * A trailing `--comparison-right-edge-gap` track lets the last column scroll
43
+ * clear of the container edge (see `.headerRow::after` below). The mobile
44
+ * layout always scrolls horizontally when it overflows — it's swapped out for
45
+ * the desktop layout at the `s` breakpoint — so the gap needs no breakpoint
46
+ * reset.
47
+ */
48
+ --comparison-right-edge-gap: 32px;
49
+ --cols: repeat(
50
+ var(--comparison-columns),
51
+ calc((1024px - 256px) / var(--comparison-columns))
52
+ )
53
+ var(--comparison-right-edge-gap);
54
+
55
+ display: flex;
56
+ flex-direction: column;
57
+ width: 100%;
58
+ overflow-x: auto;
59
+ }
60
+
61
+ /* Per-column rows: own grid, shared column template. */
62
+ .headerRow,
63
+ .subHeaderRow,
64
+ .row {
65
+ display: grid;
66
+ grid-template-columns: var(--cols);
67
+ min-width: 100%;
68
+ }
69
+
70
+ /*
71
+ * Right-edge gap for the horizontal table scrolling
72
+ */
73
+ .headerRow::after,
74
+ .subHeaderRow::after,
75
+ .row::after {
76
+ content: "";
77
+ grid-column: -2 / -1;
78
+ }
79
+
80
+ .headerCell {
81
+ --header-cell-pad-top: var(--sem-space-padding-110);
82
+ --header-cell-pad-right: var(--sem-space-padding-110);
83
+ --header-cell-pad-left: var(--sem-space-padding-110);
84
+
85
+ display: flex;
86
+ flex-direction: column;
87
+ justify-content: flex-end;
88
+ padding: var(--sem-space-padding-110);
89
+ background-color: var(--sem-color-bg-secondary-light-default);
90
+ border-bottom: var(--sem-border-width-xxs) solid
91
+ var(--sem-color-border-secondary-light-minimal);
92
+ }
93
+
94
+ /*
95
+ * Flush the promoted banner to the top and sides of its header cell by pulling
96
+ * it into the cell's padding, so the banner always spans the full column width.
97
+ * Each header cell exposes its own left/right padding via custom properties
98
+ * (`--header-cell-pad-left` / `--header-cell-pad-right`); the banner cancels
99
+ * exactly those, so it matches whatever padding the cell uses. The top corners
100
+ * inherit the highlighted column's 32px radius so the banner sits inside them.
101
+ */
102
+ .headerCellPromoted {
103
+ margin-top: calc(
104
+ -1 * var(--header-cell-pad-top, var(--sem-space-padding-110))
105
+ );
106
+ margin-right: calc(
107
+ -1 * var(--header-cell-pad-right, var(--sem-space-padding-110))
108
+ );
109
+ margin-bottom: var(--sem-space-gap-40);
110
+ margin-left: calc(
111
+ -1 * var(--header-cell-pad-left, var(--sem-space-padding-110))
112
+ );
113
+ border-radius: 0;
114
+ overflow: hidden;
115
+ text-align: center;
116
+ }
117
+
118
+ .highlightedTop .headerCellPromoted {
119
+ border-top-left-radius: 32px;
120
+ border-top-right-radius: 32px;
121
+ }
122
+
123
+ /*
124
+ * Opt-in sticky header: the header row is now a real box, so it can stick to
125
+ * the top of the scroll container directly.
126
+ */
127
+ .stickyHeader {
128
+ position: sticky;
129
+ top: 0;
130
+ z-index: 1;
131
+ background: var(--sem-color-bg-secondary-light-default);
132
+ box-shadow: 0 1px 0 0 rgba(0, 0, 0, 12%);
133
+ }
134
+
135
+ .subHeaderCell {
136
+ display: flex;
137
+ flex-direction: column;
138
+ justify-content: space-between;
139
+ height: 100%;
140
+ padding: 24px 32px 32px;
141
+ border-top: var(--sem-border-width-xxs) solid
142
+ var(--sem-color-border-secondary-light-minimal);
143
+ border-bottom: var(--sem-border-width-xxs) solid
144
+ var(--sem-color-border-secondary-light-minimal);
145
+ }
146
+
147
+ @media (--arc-custom-media-min-width-s-px) {
148
+ .subHeaderCell {
149
+ border-top: none;
150
+ }
151
+ }
152
+
153
+ .rowGroup {
154
+ display: flex;
155
+ flex-direction: column;
156
+ }
157
+
158
+ /*
159
+ * The feature band spans the table's full content width (not just the viewport)
160
+ * when the table scrolls horizontally: the row mirrors the data-row grid and
161
+ * the band spans every column.
162
+ */
163
+ .featureRow {
164
+ display: grid;
165
+ grid-template-columns: var(--cols);
166
+ min-width: 100%;
167
+ }
168
+
169
+ .featureRowHeader {
170
+ grid-column: 1 / -2;
171
+ background-color: var(--sem-color-bg-secondary-light-default);
172
+ }
173
+
174
+ /* Mobile content-row cells carry the bottom border (not the feature band above them). */
175
+ .row .cell {
176
+ border-bottom: var(--sem-border-width-xxs) solid
177
+ var(--sem-color-border-secondary-light-minimal);
178
+ }
179
+
180
+ /*
181
+ * xs / s / m: the table scrolls horizontally, so pin the feature header's
182
+ * content to the left edge of the viewport while the columns scroll.
183
+ */
184
+ .featureRowHeaderContent {
185
+ position: sticky;
186
+ left: 0;
187
+ width: calc((1024px - 256px) / var(--comparison-columns));
188
+ }
189
+
190
+ /* s and up: the mobile layout is hidden, so no horizontal scroll to pin against. */
191
+ @media (--arc-custom-media-min-width-s-px) {
192
+ .featureRowHeaderContent {
193
+ position: static;
194
+ width: auto;
195
+ }
196
+ }
197
+
198
+ /*
199
+ * The control row mirrors the per-column grid (like `.featureRow`) so it spans
200
+ * the table's full content width and scrolls the whole width, not just the
201
+ * viewport. The single control cell spans every track.
202
+ */
203
+ .controlRow {
204
+ display: grid;
205
+ grid-template-columns: var(--cols);
206
+ min-width: 100%;
207
+ }
208
+
209
+ /*
210
+ * The control cell spans the full row width so the row still scrolls the whole
211
+ * table width. The sticky, left-pinned behaviour lives on the inner
212
+ * `.controlCellContent` instead, mirroring the feature band
213
+ * (`.featureRowHeader`) / sticky content (`.featureRowHeaderContent`) split.
214
+ */
215
+ .controlCell {
216
+ grid-column: 1 / -2;
217
+ display: flex;
218
+ padding: 16px;
219
+ }
220
+
221
+ /*
222
+ * xs / s / m: pin the controls to the left edge of the viewport while the
223
+ * columns scroll horizontally, matching the feature cell. The bounded width is
224
+ * what makes the sticky pin visible — one plan-column width keeps it aligned
225
+ * with `.featureRowHeaderContent`.
226
+ */
227
+ .controlCellContent {
228
+ display: flex;
229
+ position: sticky;
230
+ left: 0;
231
+ width: calc((1024px - 256px) / var(--comparison-columns));
232
+ flex-wrap: wrap;
233
+ gap: var(--sem-space-gap-60);
234
+ align-items: center;
235
+ }
236
+
237
+ /* s and up: the mobile layout is hidden, so no horizontal scroll to pin against. */
238
+ @media (--arc-custom-media-min-width-s-px) {
239
+ .controlCell {
240
+ padding: 16px 16px 48px 0;
241
+ }
242
+
243
+ .controlCellContent {
244
+ position: static;
245
+ width: auto;
246
+ }
247
+ }
248
+
249
+ .cell {
250
+ display: flex;
251
+ align-items: center;
252
+ justify-content: center;
253
+ padding: 16px 32px;
254
+ text-align: center;
255
+ }
256
+
257
+ .highlighted {
258
+ background: var(--surface-colors-bg-light-brand-02);
259
+ }
260
+
261
+ .highlightedTop {
262
+ border-top-left-radius: 32px;
263
+ border-top-right-radius: 32px;
264
+ }
265
+
266
+ .highlightedBottom {
267
+ border-bottom-left-radius: 32px;
268
+ border-bottom-right-radius: 32px;
269
+ }
270
+
271
+ /* Breakpoint layout toggle: mobile layout below s, desktop at s and up. */
272
+ .mobileLayout {
273
+ display: flex;
274
+ }
275
+
276
+ .desktopLayout {
277
+ display: none;
278
+ }
279
+
280
+ @media (--arc-custom-media-min-width-s-px) {
281
+ .mobileLayout {
282
+ display: none;
283
+ }
284
+
285
+ .desktopLayout {
286
+ display: flex;
287
+ }
288
+ }
289
+
290
+ /*
291
+ * Desktop layout: feature as a left-hand column, plan columns to the right.
292
+ * The desktop table is a vertical flex stack of real row boxes. Each row is its
293
+ * own grid sharing --desktop-cols (a leading feature column + one track per
294
+ * plan column), so columns line up and each row stays a positionable box.
295
+ */
296
+ .desktopTable {
297
+ /*
298
+ * s to l: feature column + fixed-width plan columns, each (1024px - feature
299
+ * column 256px) / number of columns. The table scrolls horizontally if they
300
+ * overflow.
301
+ */
302
+ --comparison-right-edge-gap: 32px;
303
+ --desktop-cols: minmax(144px, 256px)
304
+ repeat(
305
+ var(--comparison-columns),
306
+ calc((1024px - 256px) / var(--comparison-columns))
307
+ )
308
+ var(--comparison-right-edge-gap);
309
+
310
+ flex-direction: column;
311
+ width: 100%;
312
+ overflow-x: auto;
313
+ }
314
+
315
+ /*
316
+ * Sticky header + horizontal scroll on one element: `overflow-x: auto` already
317
+ * promotes the computed `overflow-y` to `auto`, so the table is the sticky
318
+ * header's scroll container. Bounding its height gives the body something to
319
+ * scroll vertically against, so the header pins to the table's top while rows
320
+ * scroll beneath it — and columns still scroll horizontally in the same box.
321
+ * This composes with `hasBorder`. Consumers can tune the height via the
322
+ * `--comparison-table-max-height` custom property.
323
+ */
324
+ .desktopTableSticky {
325
+ max-height: 100dvh;
326
+ overflow-y: auto;
327
+ }
328
+
329
+ /*
330
+ * l and up: plan columns grow to fill the remaining width equally.
331
+ */
332
+ @media (--arc-custom-media-min-width-l-px) {
333
+ .desktopTable {
334
+ /* Columns flex to fill the width here; no horizontal scroll, so no gap. */
335
+ --comparison-right-edge-gap: 0px;
336
+ --desktop-cols: minmax(144px, 256px)
337
+ repeat(var(--comparison-columns), minmax(0, 1fr))
338
+ var(--comparison-right-edge-gap);
339
+ }
340
+ }
341
+
342
+ .desktopHeaderRow,
343
+ .desktopSubHeaderRow,
344
+ .desktopRow {
345
+ display: grid;
346
+ grid-template-columns: var(--desktop-cols);
347
+ min-width: 100%;
348
+ }
349
+
350
+ /*
351
+ * Right-edge gap for the horizontal table scrolling
352
+ */
353
+ .desktopHeaderRow::after,
354
+ .desktopSubHeaderRow::after,
355
+ .desktopRow::after {
356
+ content: "";
357
+ grid-column: -2 / -1;
358
+ }
359
+
360
+ .desktopFeatureHeader,
361
+ .desktopHeaderCell {
362
+ --header-cell-pad-top: var(--sem-space-padding-110);
363
+ --header-cell-pad-right: 0;
364
+ --header-cell-pad-left: var(--sem-space-padding-110);
365
+
366
+ display: flex;
367
+ flex-direction: column;
368
+ justify-content: flex-end;
369
+ padding: var(--sem-space-padding-110) 0 var(--sem-space-padding-110)
370
+ var(--sem-space-padding-110);
371
+ background-color: var(--sem-color-bg-secondary-light-default);
372
+ border-bottom: var(--sem-border-width-xxs) solid
373
+ var(--sem-color-border-secondary-light-minimal);
374
+ }
375
+
376
+ .desktopControlsCell {
377
+ display: flex;
378
+ flex-direction: column;
379
+ gap: var(--sem-space-gap-60);
380
+ justify-content: flex-end;
381
+ padding: 16px 16px 48px 0;
382
+ border-bottom: var(--sem-border-width-xxs) solid
383
+ var(--sem-color-border-secondary-light-minimal);
384
+ }
385
+
386
+ .desktopFeatureCell {
387
+ border-bottom: var(--sem-border-width-xxs) solid
388
+ var(--sem-color-border-secondary-light-minimal);
389
+ }
390
+
391
+ /* Desktop value cells get a bottom border per row (the feature cell already has one). */
392
+ .desktopRow .cell {
393
+ border-bottom: var(--sem-border-width-xxs) solid
394
+ var(--sem-color-border-secondary-light-minimal);
395
+ }
396
+
397
+ /*
398
+ * Highlighted desktop header cells: override the default grey header background
399
+ * with the brand highlight (higher specificity than .desktopHeaderCell alone).
400
+ */
401
+ .desktopHeaderCell.highlighted {
402
+ background: var(--surface-colors-bg-light-brand-02);
403
+ }
404
+
405
+ /* ------------------------------------------------------------------ */
406
+
407
+ /*
408
+ * Collapsible section toggle + content (both layouts). The toggle row mirrors
409
+ * each layout's per-column grid so it spans the table's full content width (not
410
+ * just the viewport) and scrolls the whole width, like the feature and control
411
+ * rows. The toggle button spans every column via `1 / -1`; the per-layout
412
+ * column template is applied below since mobile and desktop use different track
413
+ * variables.
414
+ */
415
+ .sectionToggleRow {
416
+ display: grid;
417
+ min-width: 100%;
418
+ }
419
+
420
+ .table .sectionToggleRow {
421
+ grid-template-columns: var(--cols);
422
+ }
423
+
424
+ .desktopTable .sectionToggleRow {
425
+ grid-template-columns: var(--desktop-cols);
426
+ }
427
+
428
+ .sectionToggleRow .sectionToggle {
429
+ grid-column: 1 / -2;
430
+ }
431
+
432
+ .sectionContent {
433
+ display: flex;
434
+ flex-direction: column;
435
+ }
436
+
437
+ .sectionToggle {
438
+ display: flex;
439
+ width: 100%;
440
+ align-items: center;
441
+ justify-content: flex-start;
442
+ padding: var(--sem-space-padding-110);
443
+ padding-left: 0;
444
+ background: var(--sem-color-bg-secondary-light-default);
445
+ border: none;
446
+ border-bottom: var(--sem-border-width-xxs) solid
447
+ var(--sem-color-border-secondary-light-minimal);
448
+ text-align: left;
449
+ cursor: pointer;
450
+ appearance: none;
451
+ }
452
+
453
+ /*
454
+ * The heading + chevron, pinned to the left edge of the viewport while the
455
+ * table scrolls horizontally, matching the feature and control cells. The
456
+ * button spans the full width (so its background and divider border do too);
457
+ * only this inner content sticks. `width: fit-content` keeps the pinned box no
458
+ * wider than the heading so it tracks the left edge rather than stretching.
459
+ */
460
+ .sectionToggleContent {
461
+ display: flex;
462
+ position: sticky;
463
+ left: 0;
464
+ width: fit-content;
465
+ align-items: center;
466
+ gap: var(--sem-space-gap-40);
467
+ }
468
+
469
+ /* Open: drop the bottom border; the first revealed row provides the divider. */
470
+ .sectionToggleOpen {
471
+ border-bottom: none;
472
+ }
473
+
474
+ .sectionChevron {
475
+ display: inline-flex;
476
+ transition: transform 0.2s ease;
477
+ }
478
+
479
+ .sectionChevronOpen {
480
+ transform: rotate(180deg);
481
+ }
482
+
483
+ @media (prefers-reduced-motion: reduce) {
484
+ .sectionChevron {
485
+ transition: none;
486
+ }
487
+ }
488
+
489
+ /* The last mobile row has no bottom border. */
490
+ .rowGroup.isLastRow:last-child .row .cell {
491
+ border-bottom: none;
492
+ }
493
+
494
+ /* The last desktop row has no bottom border. */
495
+ .desktopRow.isLastRow .desktopFeatureCell,
496
+ .desktopRow.isLastRow .cell {
497
+ border-bottom: none;
498
+ }
499
+
500
+ /* A closed last section's toggle is the table's last row: no bottom divider. */
501
+ .sectionToggleRow.isLastRow .sectionToggle {
502
+ border-bottom: none;
503
+ }
@@ -0,0 +1,104 @@
1
+ import React from "react";
2
+ import classNames from "classnames";
3
+
4
+ import { filterAttrs } from "@arc-ui/community-utils/filter-attrs";
5
+
6
+ import { MobileTable } from "./components/MobileTable/MobileTable";
7
+ import { DesktopTable } from "./components/DesktopTable/DesktopTable";
8
+ import styles from "./ComparisonTable.module.css";
9
+ import { type ComparisonColumn } from "./types/comparison-column";
10
+ import { type ComparisonRow } from "./types/comparison-row";
11
+ import { type ComparisonSection } from "./types/comparison-section";
12
+ import { groupRows } from "./utils/group-rows";
13
+
14
+ export const ComparisonTable: React.FC<ComparisonTableProps> = ({
15
+ columns,
16
+ rows,
17
+ sections,
18
+ controls,
19
+ hasBorder = true,
20
+ hasStickyHeader = false,
21
+ ...props
22
+ }) => {
23
+ const { unsectioned, sections: resolvedSections } = React.useMemo(
24
+ () => groupRows(rows, sections),
25
+ [rows, sections],
26
+ );
27
+
28
+ return (
29
+ <div
30
+ className={classNames(styles.wrapper, {
31
+ [styles.wrapperBordered]: hasBorder,
32
+ })}
33
+ {...filterAttrs(props)}
34
+ >
35
+ {hasBorder && <div className={styles.shadowOverlay} aria-hidden="true" />}
36
+ <MobileTable
37
+ className={styles.mobileLayout}
38
+ columns={columns}
39
+ rows={unsectioned}
40
+ sections={resolvedSections}
41
+ controls={controls}
42
+ />
43
+ <DesktopTable
44
+ className={styles.desktopLayout}
45
+ columns={columns}
46
+ rows={unsectioned}
47
+ sections={resolvedSections}
48
+ controls={controls}
49
+ hasStickyHeader={hasStickyHeader}
50
+ />
51
+ </div>
52
+ );
53
+ };
54
+
55
+ export interface ComparisonTableProps {
56
+ /**
57
+ * Column definitions driving the header cells and the number of content cells
58
+ * per row. Each `ComparisonColumn` has:
59
+ *
60
+ * - `id` (`string`) — Stable identifier; used as a React key.
61
+ * - `label` (`string`) — Visible text in the column header (e.g. a plan name).
62
+ * - `promoted` (`string`, optional) — Promotional banner text shown above the label in a branded Surface.
63
+ * - `subheader` (`SubHeaderCellProps`, optional) — Price, price-increase items, and a CTA button rendered in a row beneath the header.
64
+ */
65
+ columns: ComparisonColumn[];
66
+ /**
67
+ * All rows. Rows without `sectionId` render first, before any sections. Rows
68
+ * with a `sectionId` render inside the matching section, in input order. On
69
+ * mobile each row is a full-width feature header above its content row(s); on
70
+ * desktop the feature becomes the left-hand column of the row. Each
71
+ * `ComparisonRow` has:
72
+ *
73
+ * - `feature` (`FeatureCellProps`) — Icon, title, description, and optional popover for the feature label.
74
+ * - `cells` (`ReactNode[]`) — One cell per column, in the same order as `columns`. Supports strings or rich ReactNode content (e.g. tick/cross icons).
75
+ * - `subRows` (`ComparisonSubRow[]`, optional) — Additional content rows beneath the primary row, for features spanning multiple rows.
76
+ * - `sectionId` (`string`, optional) — Id of the section the row belongs to. An unknown id logs a dev warning and the row renders unsectioned.
77
+ */
78
+ rows?: ComparisonRow[];
79
+ /**
80
+ * Section metadata only. Sections render in array order, after unsectioned
81
+ * rows, each with a full-width toggle that expands or collapses the rows whose
82
+ * `sectionId` matches. A section with no matching rows is hidden. Each `ComparisonSection` has:
83
+ *
84
+ * - `id` (`string`) — Stable identifier; used as a React key and for accessibility linking.
85
+ * - `heading` (`ReactNode`) — Text shown in the section's toggle button.
86
+ * - `headingLevel` (`HeadingLevel`, optional) — Semantic heading level for the document outline.
87
+ * - `defaultOpen` (`boolean`, optional) — Initial open state when uncontrolled. Defaults to `false`.
88
+ * - `open` (`boolean`, optional) — Controlled open state.
89
+ * - `onOpenChange` (`(open: boolean) => void`, optional) — Callback for controlled usage.
90
+ */
91
+ sections?: ComparisonSection[];
92
+ /**
93
+ * Optional controls. On mobile they render in a full-width cell beneath the header; on desktop they render in the left-hand feature column beside the sub-header. Omit to hide the control row entirely.
94
+ */
95
+ controls?: React.ReactNode;
96
+ /**
97
+ * When `true`, wraps the table in a rounded, clipped container with an inset shadow overlay border. Defaults to `false`.
98
+ */
99
+ hasBorder?: boolean;
100
+ /**
101
+ * When `true`, the desktop column header row sticks to the top of the scroll container as the body scrolls. Defaults to `false`.
102
+ */
103
+ hasStickyHeader?: boolean;
104
+ }
@@ -0,0 +1,44 @@
1
+ import React from "react";
2
+ import classNames from "classnames";
3
+
4
+ import { filterAttrs } from "@arc-ui/community-utils/filter-attrs";
5
+ import { Text } from "@arc-ui/components/Text";
6
+
7
+ import styles from "../../ComparisonTable.module.css";
8
+
9
+ export const Cell: React.FC<CellProps> = ({
10
+ children,
11
+ columnIndex = 0,
12
+ isLastRow = false,
13
+ ...props
14
+ }) => {
15
+ const isHighlighted = columnIndex % 2 === 0;
16
+
17
+ return (
18
+ <div
19
+ role="cell"
20
+ className={classNames(styles.cell, {
21
+ [styles.highlighted]: isHighlighted,
22
+ [styles.highlightedBottom]: isHighlighted && isLastRow,
23
+ })}
24
+ {...filterAttrs(props)}
25
+ >
26
+ <Text>{children}</Text>
27
+ </div>
28
+ );
29
+ };
30
+
31
+ export interface CellProps {
32
+ /**
33
+ * Content rendered inside the content cell (text or an Arc primitive such as a tick/cross).
34
+ */
35
+ children?: React.ReactNode;
36
+ /**
37
+ * Zero-based index of the column this cell belongs to. Used to highlight every other column, starting with the first.
38
+ */
39
+ columnIndex?: number;
40
+ /**
41
+ * Whether this cell sits in the last row of the table. Highlighted cells in the last row get rounded bottom corners.
42
+ */
43
+ isLastRow?: boolean;
44
+ }
@@ -0,0 +1,21 @@
1
+ import React from "react";
2
+
3
+ import { filterAttrs } from "@arc-ui/community-utils/filter-attrs";
4
+
5
+ import styles from "../../ComparisonTable.module.css";
6
+
7
+ export const ControlCell: React.FC<ControlCellProps> = ({
8
+ children,
9
+ ...props
10
+ }) => (
11
+ <div role="cell" className={styles.controlCell} {...filterAttrs(props)}>
12
+ <div className={styles.controlCellContent}>{children}</div>
13
+ </div>
14
+ );
15
+
16
+ export interface ControlCellProps {
17
+ /**
18
+ * Control content rendered for this column.
19
+ */
20
+ children?: React.ReactNode;
21
+ }