@repobit/dex-system-design 0.24.20 → 0.25.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/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [0.25.0](https://github.com/bitdefender/dex-core/compare/@repobit/dex-system-design@0.24.20...@repobit/dex-system-design@0.25.0) (2026-09-25)
7
+
8
+ ### Features
9
+
10
+ * **DEX-28731:** add align-rows subgrid option to card section
11
+
12
+
6
13
  ## [0.24.20](https://github.com/bitdefender/dex-core/compare/@repobit/dex-system-design@0.24.19...@repobit/dex-system-design@0.24.20) (2026-09-24)
7
14
 
8
15
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@repobit/dex-system-design",
3
- "version": "0.24.20",
3
+ "version": "0.25.0",
4
4
  "description": "Design system based on Web Components.",
5
5
  "author": "Iordache Matei Cezar <miordache@bitdefender.com>",
6
6
  "homepage": "https://github.com/bitdefender/dex-core#readme",
@@ -76,8 +76,8 @@
76
76
  "url": "https://github.com/bitdefender/dex-core/issues"
77
77
  },
78
78
  "dependencies": {
79
- "@repobit/dex-store": "1.6.36",
80
- "@repobit/dex-store-elements": "2.1.6",
79
+ "@repobit/dex-store": "1.6.37",
80
+ "@repobit/dex-store-elements": "2.1.7",
81
81
  "lit": "^3.3.2"
82
82
  },
83
83
  "devDependencies": {
@@ -94,5 +94,5 @@
94
94
  "volta": {
95
95
  "node": "24.14.0"
96
96
  },
97
- "gitHead": "c5b184f2837dad5acab88a931b12d3d51121ed57"
97
+ "gitHead": "1e626106ffdf4df5443c8da6fa45fa4dae48a2c7"
98
98
  }
@@ -260,6 +260,68 @@ export default css`
260
260
  display: none;
261
261
  }
262
262
 
263
+ /* === ALIGN ROWS — opt-in with align-rows on bd-card-section. The track is
264
+ a grid and every card is a subgrid spanning the same rows (header, title,
265
+ one per content node, CTA), so rows line up across cards even when their
266
+ content wraps differently. Row counts come from _syncAlignRows(). Row gaps
267
+ between tracks stay 0 so spacing inside cards is unchanged; spacing
268
+ between wrapped card rows comes from the item margin instead. === */
269
+ @media (min-width: 769px) {
270
+ :host([align-rows]) .bd-light-carousel-track-s {
271
+ display: grid;
272
+ grid-template-columns: repeat(var(--bd-card-cols), minmax(0, 1fr));
273
+ column-gap: 1.5rem;
274
+ row-gap: 0;
275
+ margin-bottom: -1.5rem;
276
+ }
277
+
278
+ :host([align-rows]) ::slotted(bd-card-item) {
279
+ display: grid;
280
+ grid-template-rows: subgrid;
281
+ grid-row: span var(--bd-card-rows, 4);
282
+ width: auto;
283
+ min-width: 0;
284
+ margin: 0 0 1.5rem !important;
285
+ }
286
+
287
+ :host([align-rows]) .bd-light-box-s {
288
+ display: grid;
289
+ grid-template-rows: subgrid;
290
+ grid-row: 1 / -1;
291
+ row-gap: 0;
292
+ /* center/outline boxes force flex centering with !important; in grid
293
+ those would center items vertically and shrink the column */
294
+ align-items: start !important;
295
+ justify-content: normal !important;
296
+ align-content: start;
297
+ }
298
+
299
+ :host([align-rows]) .bd-light-box-header-s {
300
+ grid-row: 1;
301
+ }
302
+
303
+ :host([align-rows]) .bd-light-title-badge-wrapper-s {
304
+ grid-row: 2;
305
+ }
306
+
307
+ :host([align-rows]) .bd-card-content-wrapper {
308
+ display: grid;
309
+ grid-template-rows: subgrid;
310
+ grid-row: 3 / span var(--bd-card-content-rows, 1);
311
+ row-gap: 0;
312
+ align-items: start;
313
+ }
314
+
315
+ :host([align-rows]) .bd-light-box-center .bd-card-content-wrapper,
316
+ :host([align-rows]) .bd-light-box-outline .bd-card-content-wrapper {
317
+ justify-items: center;
318
+ }
319
+
320
+ :host([align-rows]) .bd-card-cta-wrapper {
321
+ grid-row: -2;
322
+ }
323
+ }
324
+
263
325
 
264
326
  @media (max-width: 768px) {
265
327
  .bd-light-carousel-track-s {
@@ -16,6 +16,10 @@ class Card extends LitElement {
16
16
  // on the section, unless a specific item needs to opt out by setting
17
17
  // its own variant attribute explicitly.
18
18
  variant : { type: String, reflect: true },
19
+ // Opt-in: lays the cards out on a CSS grid and makes every card a subgrid,
20
+ // so icon, title, each content row and CTA line up across cards (desktop
21
+ // only). Replaces the JS height equalization for this section.
22
+ alignRows : { type: Boolean, reflect: true, attribute: "align-rows" },
19
23
  _itemCount: { state: true }
20
24
  };
21
25
 
@@ -25,8 +29,10 @@ class Card extends LitElement {
25
29
  this.subtitle = "";
26
30
  this.bgBlue = false;
27
31
  this.variant = "";
32
+ this.alignRows = false;
28
33
  this._itemCount = 0;
29
34
  this._resizeObserver = null;
35
+ this._mutationObserver = null;
30
36
  }
31
37
 
32
38
  _isVisible() {
@@ -72,16 +78,51 @@ class Card extends LitElement {
72
78
  });
73
79
  }
74
80
 
81
+ // Mirrors align-rows onto every item (their shadow styles key off it) and
82
+ // sizes the shared row tracks: header, title, the longest content list, CTA.
83
+ _syncAlignRows() {
84
+ const items = [...this.querySelectorAll("bd-card-item")];
85
+ items.forEach((item) => item.toggleAttribute("align-rows", this.alignRows));
86
+ if (!this.alignRows) return;
87
+
88
+ const contentRows = Math.max(0, ...items.map((item) => item._contentNodes?.().length ?? 0));
89
+ this.style.setProperty("--bd-card-content-rows", contentRows);
90
+ this.style.setProperty("--bd-card-rows", contentRows + 3);
91
+ }
92
+
93
+ updated(changed) {
94
+ if (!changed.has("alignRows")) return;
95
+ this._syncAlignRows();
96
+ if (this.alignRows) this._clearEqualizedHeights();
97
+ else this._equalizeHeights();
98
+ }
99
+
100
+ // Removes the inline heights written by _equalizeHeights (e.g. when
101
+ // align-rows is switched on after the section already rendered).
102
+ _clearEqualizedHeights() {
103
+ this.querySelectorAll("bd-card-item").forEach((item) => {
104
+ const root = item.renderRoot;
105
+ if (!root) return;
106
+ [".bd-light-box-s", ".bd-light-box-header-s", ".bd-light-title-badge-wrapper-s", ".bd-card-content-wrapper"]
107
+ .forEach((selector) => {
108
+ const el = root.querySelector(selector);
109
+ if (el) { el.style.height = ""; el.style.minHeight = ""; }
110
+ });
111
+ });
112
+ }
113
+
75
114
  _onSlotChange = () => {
76
115
  this._itemCount = this.querySelectorAll("bd-card-item").length;
77
116
  this._propagateVariant();
117
+ this._syncAlignRows();
78
118
  this._updateCardWidth();
79
119
  this.requestUpdate();
80
120
  this._equalizeHeights();
81
121
  }
82
122
 
83
123
  _equalizeHeights() {
84
- if (!this._isVisible()) return;
124
+ // align-rows sizes cards with subgrid; inline heights would fight it
125
+ if (this.alignRows || !this._isVisible()) return;
85
126
 
86
127
  const items = Array.from(this.querySelectorAll("bd-card-item"));
87
128
  if (!items.length) return;
@@ -187,6 +228,7 @@ class Card extends LitElement {
187
228
 
188
229
  await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
189
230
  this._propagateVariant();
231
+ this._syncAlignRows();
190
232
  this._updateCardWidth();
191
233
  this._equalizeHeights();
192
234
 
@@ -198,11 +240,17 @@ class Card extends LitElement {
198
240
  });
199
241
  this._resizeObserver.observe(track);
200
242
  }
243
+
244
+ // Content added to or removed from an item changes its row count, which
245
+ // doesn't trigger slotchange on the section.
246
+ this._mutationObserver = new MutationObserver(() => this._syncAlignRows());
247
+ this._mutationObserver.observe(this, { childList: true, subtree: true });
201
248
  }
202
249
 
203
250
  disconnectedCallback() {
204
251
  super.disconnectedCallback();
205
252
  this._resizeObserver?.disconnect();
253
+ this._mutationObserver?.disconnect();
206
254
  }
207
255
 
208
256
  render() {
@@ -255,7 +303,7 @@ class CardItem extends LitElement {
255
303
  this.icon = "";
256
304
  this.align = "start";
257
305
  this.bgBlue = false;
258
- this.ctaAlign = "start";
306
+ this.ctaAlign = "";
259
307
  this.variant = "";
260
308
  }
261
309
 
@@ -310,16 +358,19 @@ class CardItem extends LitElement {
310
358
 
311
359
  return "";
312
360
  }
313
- _hasContent() {
314
- const slottedNodes = [...this.childNodes].filter((node) => {
361
+ // Nodes rendered in the default slot — each one is a content row
362
+ _contentNodes() {
363
+ return [...this.childNodes].filter((node) => {
315
364
  if (node.nodeType === Node.ELEMENT_NODE) {
316
365
  const slot = node.getAttribute("slot");
317
366
  return slot !== "icon" && slot !== "cta";
318
367
  }
319
368
  return node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== "";
320
369
  });
370
+ }
321
371
 
322
- return slottedNodes.length > 0;
372
+ _hasContent() {
373
+ return this._contentNodes().length > 0;
323
374
  }
324
375
 
325
376
  render() {
@@ -327,9 +378,10 @@ class CardItem extends LitElement {
327
378
  const isCentered = this.align === "center" || isOutline;
328
379
  const hasContent = this._hasContent();
329
380
  const hasCtaSlot = !!this.querySelector("[slot='cta']");
330
- const ctaJustify = this.ctaAlign === "center"
381
+ const ctaAlign = this.ctaAlign || (isCentered ? "center" : "start");
382
+ const ctaJustify = ctaAlign === "center"
331
383
  ? "justify-content: center;"
332
- : this.ctaAlign === "end"
384
+ : ctaAlign === "end"
333
385
  ? "justify-content: flex-end;"
334
386
  : "justify-content: flex-start;";
335
387
 
@@ -34,6 +34,13 @@ to a bordered, light-blue-tinted, centered card style (blue title, centered
34
34
  icon/text) — set it once on the section, it propagates to all items
35
35
  automatically. Leaving \`variant\` unset keeps the existing grey-box look.
36
36
  An individual \`bd-card-item\` can opt out with \`variant=""\` explicitly.
37
+
38
+ ### Align rows
39
+ \`align-rows\` on \`bd-card-section\` lays the cards out with CSS subgrid
40
+ (above 768px): icon, title, each content element and the CTA line up across
41
+ cards, even when one card's text wraps to more lines. It replaces the JS
42
+ height equalization for that section. Note: an incomplete last row is
43
+ left-aligned instead of centered.
37
44
  `
38
45
  }
39
46
  }
@@ -250,6 +257,81 @@ export const WithCTA = {
250
257
  parameters: { docs: { description: { story: 'Cards cu buton CTA via `slot="cta"`.' } } }
251
258
  };
252
259
 
260
+ // ─── Align Rows ───────────────────────────────────────────────────────────────
261
+
262
+ const alignRowsIcons = [
263
+ html`<bd-individual-icon slot="icon" width="32" height="32" color="#006DFF"></bd-individual-icon>`,
264
+ html`<bd-globe-icon slot="icon" width="32" height="32" color="#006DFF"></bd-globe-icon>`,
265
+ html`<bd-machine-learning-icon slot="icon" width="32" height="32" color="#006DFF"></bd-machine-learning-icon>`,
266
+ html`<bd-analysis-icon slot="icon" width="32" height="32" color="#006DFF"></bd-analysis-icon>`
267
+ ];
268
+
269
+ // Store badges rendered as linked pictures (a content row), like on product pages
270
+ const storeBadges = [
271
+ { src: '/images/badges/google-play-badge.png', alt: 'Get it on Google Play' },
272
+ { src: '/images/badges/app-store-badge.png', alt: 'Download on the App Store' }
273
+ ];
274
+
275
+ const storeBadge = (n) => {
276
+ const badge = storeBadges[(n - 1) % storeBadges.length];
277
+ return html`
278
+ <a href="#" style="display: block;">
279
+ <img src="${badge.src}" alt="${badge.alt}" width="188" height="56" style="display: block; width: auto; height: 56px;" />
280
+ </a>
281
+ `;
282
+ };
283
+
284
+ export const AlignRows = {
285
+ name: 'Align Rows (subgrid)',
286
+ args: {
287
+ alignRows : true,
288
+ align : 'center',
289
+ variant : '',
290
+ showCta : true,
291
+ showBadges: true,
292
+ card1Title: 'Antivirus Free for Android',
293
+ card1Text : 'Blocks malware on your phone.',
294
+ card1Extra: 'No cost, no catch.',
295
+ card2Title: 'Virus Scanner for Mac',
296
+ card2Text : 'Catches Mac and Windows malware alike, including threats hiding in email attachments and downloads.',
297
+ card2Extra: 'Free. Forever.',
298
+ card3Title: 'Security for iOS, iPhone and iPad devices',
299
+ card3Text : 'Web protection.',
300
+ card3Extra: 'Free for iPhone and iPad.',
301
+ card4Title: '',
302
+ card4Text : '',
303
+ card4Extra: ''
304
+ },
305
+ argTypes: {
306
+ alignRows : { control: 'boolean', description: '`align-rows` on bd-card-section — toggle to compare with the JS height equalization' },
307
+ align : { control: { type: 'select' }, options: ['start', 'center'], description: 'bd-card-item align' },
308
+ variant : { control: { type: 'select' }, options: ['', 'outline'], description: 'bd-card-section variant' },
309
+ showCta : { control: 'boolean', description: 'Render a CTA (slot="cta") in every card' },
310
+ showBadges: { control: 'boolean', description: 'Render a store badge (linked picture) as the last content row — Google Play / App Store alternating' },
311
+ ...Object.fromEntries([1, 2, 3, 4].flatMap((n) => [
312
+ [`card${n}Title`, { control: 'text', table: { category: `Card ${n}` }, description: n === 4 ? 'Leave empty to hide card 4' : 'Card title' }],
313
+ [`card${n}Text`, { control: 'text', table: { category: `Card ${n}` }, description: 'First content row (bd-p)' }],
314
+ [`card${n}Extra`, { control: 'text', table: { category: `Card ${n}` }, description: 'Second content row (bd-p) — leave empty to omit' }]
315
+ ]))
316
+ },
317
+ render: (args) => html`
318
+ <bd-card-section title="Also free" variant="${args.variant}" ?align-rows=${args.alignRows}>
319
+ ${[1, 2, 3, 4]
320
+ .filter((n) => args[`card${n}Title`])
321
+ .map((n) => html`
322
+ <bd-card-item align="${args.align}" title="${args[`card${n}Title`]}">
323
+ ${alignRowsIcons[n - 1]}
324
+ ${args[`card${n}Text`] ? html`<bd-p>${args[`card${n}Text`]}</bd-p>` : ''}
325
+ ${args[`card${n}Extra`] ? html`<bd-p>${args[`card${n}Extra`]}</bd-p>` : ''}
326
+ ${args.showBadges ? storeBadge(n) : ''}
327
+ ${args.showCta ? html`<bd-button-link slot="cta" href="#" kind="primary" size="sm">Get it free</bd-button-link>` : ''}
328
+ </bd-card-item>
329
+ `)}
330
+ </bd-card-section>
331
+ `,
332
+ parameters: { docs: { description: { story: 'With `align-rows`, each row (icon, title, every content element, CTA) has the same height across cards, via CSS subgrid. Use the controls to toggle it and change card content. Rows only align above 768px — widen the canvas.' } } }
333
+ };
334
+
253
335
  // ─── No Slotted Content ───────────────────────────────────────────────────────
254
336
 
255
337
  export const NoSlottedContent = {
@@ -390,13 +472,18 @@ export const Playground = {
390
472
  itemTitle : 'AI Protection',
391
473
  align : 'start',
392
474
  bgBlue : false,
393
- ctaAlign : 'start'
475
+ ctaAlign : ''
394
476
  },
395
477
  argTypes: {
396
478
  sectionVariant: {
397
479
  control : { type: 'select' },
398
480
  options : ['', 'outline'],
399
481
  description: 'bd-card-section variant — propagates to all items'
482
+ },
483
+ ctaAlign: {
484
+ control : { type: 'select' },
485
+ options : ['', 'start', 'center', 'end'],
486
+ description: 'bd-card-item cta-align — empty follows align'
400
487
  }
401
488
  },
402
489
  render: (args) => html`