@arc-ui/community-components 3.16.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.
- package/.turbo/turbo-build.log +14 -14
- package/CHANGELOG.md +21 -0
- package/lib/ComparisonTable/ComparisonTable.cjs +317 -0
- package/lib/ComparisonTable/ComparisonTable.mjs +310 -0
- package/lib/ComparisonTable/styles.css +1 -0
- package/lib/FeaturePost/FeaturePost.cjs +12 -11
- package/lib/FeaturePost/FeaturePost.mjs +12 -11
- package/lib/FeaturePost/styles.css +1 -1
- package/lib/Highlights/Highlights.cjs +7 -7
- package/lib/Highlights/Highlights.mjs +7 -7
- package/lib/ProductCard/ProductCard.cjs +13 -35
- package/lib/ProductCard/ProductCard.mjs +12 -34
- package/lib/_shared/cjs/ProductCardPriceIncrease-BP39clav.cjs +38 -0
- package/lib/_shared/esm/ProductCardPriceIncrease-CJyG2wv-.mjs +35 -0
- package/lib/index.cjs +507 -199
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.cts +213 -22
- package/lib/index.d.mts +213 -22
- package/lib/index.mjs +502 -200
- package/lib/index.mjs.map +1 -1
- package/lib/styles.css +1 -1
- package/package.json +6 -6
- package/src/components/ComparisonTable/ComparisonTable.module.css +503 -0
- package/src/components/ComparisonTable/ComparisonTable.tsx +104 -0
- package/src/components/ComparisonTable/components/Cell/Cell.tsx +44 -0
- package/src/components/ComparisonTable/components/ControlCell/ControlCell.tsx +21 -0
- package/src/components/ComparisonTable/components/DesktopRow/DesktopRow.tsx +59 -0
- package/src/components/ComparisonTable/components/DesktopTable/DesktopTable.tsx +150 -0
- package/src/components/ComparisonTable/components/FeatureCell/FeatureCell.module.css +56 -0
- package/src/components/ComparisonTable/components/FeatureCell/FeatureCell.tsx +96 -0
- package/src/components/ComparisonTable/components/HeaderCell/HeaderCell.tsx +59 -0
- package/src/components/ComparisonTable/components/MobileRow/MobileRow.tsx +77 -0
- package/src/components/ComparisonTable/components/MobileTable/MobileTable.tsx +103 -0
- package/src/components/ComparisonTable/components/Section/Section.tsx +75 -0
- package/src/components/ComparisonTable/components/SectionToggle/SectionToggle.tsx +63 -0
- package/src/components/ComparisonTable/components/SubHeaderCell/SubHeaderCell.tsx +62 -0
- package/src/components/ComparisonTable/index.ts +22 -0
- package/src/components/ComparisonTable/types/comparison-column.ts +22 -0
- package/src/components/ComparisonTable/types/comparison-row.ts +23 -0
- package/src/components/ComparisonTable/types/comparison-section.ts +39 -0
- package/src/components/ComparisonTable/types/comparison-sub-row.ts +8 -0
- package/src/components/ComparisonTable/utils/group-rows.ts +69 -0
- package/src/components/ComparisonTable/utils/use-section-state.ts +45 -0
- package/src/components/FeaturePost/FeaturePost.module.css +24 -2
- package/src/components/FeaturePost/FeaturePost.tsx +26 -25
- package/src/components/FeaturePost/types/feature-post-cta-footer.ts +4 -3
- package/src/components/Highlights/Highlights.tsx +5 -32
- package/src/components/ProductCard/ProductCardPrice/ProductCardPrice.tsx +11 -3
- package/src/components/ProductCard/ProductCardPriceIncrease/ProductCardPriceIncrease.module.css +1 -2
- package/src/components/ProductCard/ProductCardPriceIncrease/ProductCardPriceIncrease.tsx +14 -3
- package/src/components/index.ts +1 -0
- 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
|
+
}
|