@repobit/dex-system-design 0.23.15 → 0.23.17

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.
@@ -1,15 +1,15 @@
1
- import { LitElement, html, nothing } from "lit";
1
+ import { LitElement, html, nothing } from "lit";
2
2
  import { tokens } from "../../tokens/tokens.js";
3
+ import "../Button/Button.js";
4
+ import "../link/link.js";
3
5
  import { anchorNavStyles } from "./anchor-nav.css.js";
4
6
 
5
- /** Parse token length from computed style (e.g. `8rem` → px). */
6
7
  function parseLengthToPx(value) {
7
8
  const s = (value || "").trim();
8
9
  if (!s) return 0;
9
10
  if (s.endsWith("px")) return parseFloat(s);
10
11
  if (s.endsWith("rem")) {
11
- const root =
12
- parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
12
+ const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
13
13
  return parseFloat(s) * root;
14
14
  }
15
15
  return parseFloat(s) || 0;
@@ -20,54 +20,58 @@ class BdAnchorNavItem extends HTMLElement {
20
20
  super();
21
21
  this.attachShadow({ mode: "open" });
22
22
  }
23
-
24
- connectedCallback() {
25
- this.render();
26
- }
27
-
28
- render() {
29
- this.shadowRoot.innerHTML = `<slot></slot>`;
30
- }
23
+ connectedCallback() { this.render(); }
24
+ render() { this.shadowRoot.innerHTML = `<slot></slot>`; }
31
25
  }
32
26
 
33
27
  class BdAnchorNav extends LitElement {
34
28
  static properties = {
35
29
  activeId : { type: String, state: true },
36
- /** Landmark label for the outer `<nav>` (screen readers). */
37
30
  navLabel : { type: String, attribute: "aria-label" },
38
- /** Mobile dropdown open state */
39
31
  _dropdownOpen : { type: Boolean, state: true },
40
- /** Mobile: show Buy now only after first anchor section has been scrolled past */
41
- _mobileCtaRevealed: { type: Boolean, state: true }
32
+ _mobileCtaRevealed: { type: Boolean, state: true },
33
+ ctaLabel : { type: String, attribute: "cta-label" },
34
+ ctaHref : { type: String, attribute: "cta-href" },
35
+ items : { type: Array },
36
+ maxWidth : { type: String, attribute: "max-width" }
42
37
  };
43
38
 
44
39
  static styles = [tokens, anchorNavStyles];
45
40
 
46
41
  constructor() {
47
42
  super();
48
- this.activeId = "";
49
- this.navLabel = "Section navigation";
50
- this._dropdownOpen = false;
51
- this._mobileCtaRevealed = false;
52
- this._panelId = `bd-anchor-nav-panel-${Math.random().toString(36)
43
+ this.activeId = "";
44
+ this.navLabel = "Section navigation";
45
+ this._dropdownOpen = false;
46
+ this._mobileCtaRevealed = false;
47
+ this.ctaLabel = "Buy now";
48
+ this.ctaHref = "";
49
+ this.items = null;
50
+ this.maxWidth = "";
51
+ this._panelId = `bd-anchor-nav-panel-${Math.random().toString(36)
53
52
  .slice(2, 11)}`;
54
- this._onScrollSpy = this._onScrollSpy.bind(this);
55
- this._onDocumentPointerDown = this._onDocumentPointerDown.bind(this);
56
- this._onKeydown = this._onKeydown.bind(this);
57
- this._scrollPending = false;
58
- /** While set, scrollspy must not overwrite activeId during smooth scroll to a clicked section */
53
+ this._onScrollSpy = this._onScrollSpy.bind(this);
54
+ this._onDocumentPointerDown = this._onDocumentPointerDown.bind(this);
55
+ this._onKeydown = this._onKeydown.bind(this);
56
+ this._scrollPending = false;
59
57
  this._programmaticScrollTargetId = null;
60
- this._unlockScrollSpyTimer = null;
61
- this._scrollEndUnlockHandler = null;
58
+ this._unlockScrollSpyTimer = null;
59
+ this._scrollEndUnlockHandler = null;
60
+ this._stickyOriginalTop = null;
61
+ this._stickyPlaceholder = null;
62
+ this._onStickyScroll = null;
63
+ this._handleResize = this._handleResize.bind(this);
62
64
  }
63
65
 
64
66
  connectedCallback() {
65
67
  super.connectedCallback();
66
68
  window.addEventListener("scroll", this._onScrollSpy, { passive: true });
67
69
  window.addEventListener("resize", this._onScrollSpy, { passive: true });
70
+ window.addEventListener("resize", this._handleResize);
68
71
  document.addEventListener("pointerdown", this._onDocumentPointerDown, true);
69
72
  document.addEventListener("keydown", this._onKeydown, true);
70
73
  queueMicrotask(() => this._runScrollSpy());
74
+ this._initSticky();
71
75
  }
72
76
 
73
77
  disconnectedCallback() {
@@ -75,8 +79,111 @@ class BdAnchorNav extends LitElement {
75
79
  this._cancelScrollSpyUnlock();
76
80
  window.removeEventListener("scroll", this._onScrollSpy);
77
81
  window.removeEventListener("resize", this._onScrollSpy);
82
+ window.removeEventListener("resize", this._handleResize);
78
83
  document.removeEventListener("pointerdown", this._onDocumentPointerDown, true);
79
84
  document.removeEventListener("keydown", this._onKeydown, true);
85
+ this._destroySticky();
86
+ }
87
+
88
+ _handleResize() {
89
+ this._handleMobileFullWidth();
90
+ }
91
+
92
+ _handleMobileFullWidth() {
93
+ const isMobile = window.matchMedia("(max-width: 768px)").matches;
94
+ const ctaSlot = this.shadowRoot?.querySelector('slot[name="cta"]');
95
+
96
+ if (!ctaSlot) return;
97
+
98
+ const assignedElements = ctaSlot.assignedElements();
99
+ const buttonLink = assignedElements.find(el => el.tagName === 'BD-BUTTON-LINK');
100
+
101
+ if (buttonLink) {
102
+ if (isMobile) {
103
+ buttonLink.setAttribute('fullwidth', '');
104
+ } else {
105
+ buttonLink.removeAttribute('fullwidth');
106
+ }
107
+ }
108
+ }
109
+
110
+ _initSticky() {
111
+ this._stickyOriginalTop = null;
112
+
113
+ this._onStickyScroll = () => {
114
+ const parent = this.parentElement;
115
+ if (!parent) return;
116
+
117
+ const parentRect = parent.getBoundingClientRect();
118
+ const navHeight = this._stickyPlaceholder
119
+ ? this._stickyPlaceholder.offsetHeight
120
+ : this.offsetHeight;
121
+
122
+ if (this._stickyOriginalTop === null) {
123
+ const rect = this.getBoundingClientRect();
124
+ this._stickyOriginalTop = rect.top + window.scrollY;
125
+ }
126
+
127
+ const passedTop = window.scrollY >= this._stickyOriginalTop;
128
+ const parentExited = parentRect.bottom <= navHeight;
129
+
130
+ if (passedTop && !parentExited) {
131
+ if (!this.classList.contains("is-sticky")) {
132
+ if (!this._stickyPlaceholder) {
133
+ this._stickyPlaceholder = document.createElement("div");
134
+ this._stickyPlaceholder.style.height = `${this.offsetHeight}px`;
135
+ this._stickyPlaceholder.style.display = "block";
136
+ this.parentNode.insertBefore(this._stickyPlaceholder, this);
137
+ }
138
+ this.classList.add("is-sticky");
139
+ }
140
+ this.style.setProperty("--sticky-left", `${parentRect.left}px`);
141
+ this.style.setProperty("--sticky-width", `${parentRect.width}px`);
142
+ } else {
143
+ if (this.classList.contains("is-sticky")) {
144
+ this.classList.remove("is-sticky");
145
+ this.style.removeProperty("--sticky-left");
146
+ this.style.removeProperty("--sticky-width");
147
+ if (this._stickyPlaceholder) {
148
+ this._stickyPlaceholder.remove();
149
+ this._stickyPlaceholder = null;
150
+ }
151
+ }
152
+ }
153
+ };
154
+
155
+ this._onStickyResize = () => {
156
+ this._stickyOriginalTop = null;
157
+ if (this.classList.contains("is-sticky")) {
158
+ const parent = this.parentElement;
159
+ const parentRect = parent.getBoundingClientRect();
160
+ this.style.setProperty("--sticky-left", `${parentRect.left}px`);
161
+ this.style.setProperty("--sticky-width", `${parentRect.width}px`);
162
+ }
163
+ this._onStickyScroll();
164
+ };
165
+
166
+ window.addEventListener("scroll", this._onStickyScroll, { passive: true });
167
+ window.addEventListener("resize", this._onStickyResize, { passive: true });
168
+ requestAnimationFrame(() => this._onStickyScroll());
169
+ }
170
+
171
+ _destroySticky() {
172
+ if (this._onStickyScroll) {
173
+ window.removeEventListener("scroll", this._onStickyScroll);
174
+ this._onStickyScroll = null;
175
+ }
176
+ if (this._onStickyResize) {
177
+ window.removeEventListener("resize", this._onStickyResize);
178
+ this._onStickyResize = null;
179
+ }
180
+ if (this._stickyPlaceholder) {
181
+ this._stickyPlaceholder.remove();
182
+ this._stickyPlaceholder = null;
183
+ }
184
+ this.style.removeProperty("--sticky-left");
185
+ this.style.removeProperty("--sticky-width");
186
+ this.classList.remove("is-sticky");
80
187
  }
81
188
 
82
189
  _cancelScrollSpyUnlock() {
@@ -90,7 +197,6 @@ class BdAnchorNav extends LitElement {
90
197
  }
91
198
  }
92
199
 
93
- /** After in-nav navigation, ignore scrollspy until scroll finishes (avoids flashing 2→3→4). */
94
200
  _scheduleScrollSpyUnlock() {
95
201
  this._cancelScrollSpyUnlock();
96
202
  const onScrollEnd = () => {
@@ -99,7 +205,7 @@ class BdAnchorNav extends LitElement {
99
205
  this._unlockScrollSpyTimer = null;
100
206
  }
101
207
  window.removeEventListener("scrollend", onScrollEnd);
102
- this._scrollEndUnlockHandler = null;
208
+ this._scrollEndUnlockHandler = null;
103
209
  this._programmaticScrollTargetId = null;
104
210
  this._runScrollSpy();
105
211
  };
@@ -142,52 +248,63 @@ class BdAnchorNav extends LitElement {
142
248
  });
143
249
  }
144
250
 
251
+ _resolvedItems() {
252
+ if (this.items && this.items.length) {
253
+ return this.items.map((item, i) => ({
254
+ title: item.title,
255
+ href : item.href || `#anchor-${i + 1}-section`,
256
+ id : `anchor-${i + 1}`
257
+ }));
258
+ }
259
+ return Array.from(this.children)
260
+ .filter((el) => el.tagName === "BD-ANCHOR-NAV-ITEM")
261
+ .map((el, i) => ({
262
+ title: el.getAttribute("title"),
263
+ href : el.getAttribute("href") || `#anchor-${i + 1}-section`,
264
+ id : `anchor-${i + 1}`
265
+ }));
266
+ }
267
+
145
268
  _runScrollSpy() {
146
- const count = this._navItemCount();
147
- let next = this.activeId;
269
+ const resolved = this._resolvedItems();
270
+ const count = resolved.length;
271
+ let next = this.activeId;
272
+
148
273
  if (count && !this._programmaticScrollTargetId) {
149
274
  const y = window.scrollY + this._stickyOffsetPx() + 2;
150
275
  next = "anchor-1";
151
276
  for (let i = 0; i < count; i++) {
152
- const el = document.getElementById(`anchor-${i + 1}-section`);
277
+ const sectionId = resolved[i].href.replace(/^#/, "") || `anchor-${i + 1}-section`;
278
+ const el = document.getElementById(sectionId);
153
279
  if (!el) continue;
154
280
  const top = el.getBoundingClientRect().top + window.scrollY;
155
281
  if (top <= y) next = `anchor-${i + 1}`;
156
282
  }
157
283
  }
158
284
 
159
- const isMobile = window.matchMedia("(max-width: 768px)").matches;
160
- const first = document.getElementById("anchor-1-section");
285
+ const isMobile = window.matchMedia("(max-width: 768px)").matches;
286
+ const firstHref = resolved[0]?.href.replace(/^#/, "") || "anchor-1-section";
287
+ const first = document.getElementById(firstHref);
161
288
  let nextCta;
162
- if (!isMobile) {
163
- nextCta = true;
164
- } else if (!first) {
165
- nextCta = true;
166
- } else {
167
- /* Show only while first section is fully scrolled past; hide again when it scrolls back into view */
168
- nextCta = first.getBoundingClientRect().bottom <= 0;
169
- }
289
+ if (!isMobile) nextCta = true;
290
+ else if (!first) nextCta = true;
291
+ else nextCta = first.getBoundingClientRect().bottom <= 0;
170
292
 
171
293
  if (this.activeId !== next || this._mobileCtaRevealed !== nextCta) {
172
- this.activeId = next;
294
+ this.activeId = next;
173
295
  this._mobileCtaRevealed = nextCta;
174
296
  this.requestUpdate();
175
297
  }
176
298
  }
177
299
 
178
- _navItemCount() {
179
- return Array.from(this.children).filter(
180
- (el) => el.tagName === "BD-ANCHOR-NAV-ITEM"
181
- ).length;
182
- }
183
-
184
300
  firstUpdated() {
185
- const n = this._navItemCount();
186
- if (n) {
301
+ const resolved = this._resolvedItems();
302
+ if (resolved.length) {
187
303
  this.activeId = "anchor-1";
188
304
  this.requestUpdate();
189
305
  }
190
306
  queueMicrotask(() => this._runScrollSpy());
307
+ this._handleMobileFullWidth();
191
308
  }
192
309
 
193
310
  updated(changedProperties) {
@@ -199,7 +316,6 @@ class BdAnchorNav extends LitElement {
199
316
  queueMicrotask(() => this._playMobileDropdownLabelSwap());
200
317
  }
201
318
 
202
- /** Mobile dropdown title: subtle fade + slide when scrollspy (or tap) changes section. */
203
319
  _playMobileDropdownLabelSwap() {
204
320
  if (typeof window === "undefined") return;
205
321
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
@@ -216,15 +332,11 @@ class BdAnchorNav extends LitElement {
216
332
  );
217
333
  }
218
334
 
219
- /**
220
- * Scroll clearance = actual sticky bar height + small inset (not a fixed 128px token).
221
- * Matches best practice: target headings sit just under the bar, not with extra dead space.
222
- */
223
335
  _stickyOffsetPx() {
224
- const barPx = this.offsetHeight;
336
+ const barPx = this.offsetHeight;
225
337
  const insetRaw = getComputedStyle(this).getPropertyValue("--spacing-8")
226
338
  .trim();
227
- const insetPx = parseLengthToPx(insetRaw) || 8;
339
+ const insetPx = parseLengthToPx(insetRaw) || 8;
228
340
  if (barPx > 0) return barPx + insetPx;
229
341
  const fallback = getComputedStyle(this).getPropertyValue("--spacing-128")
230
342
  .trim();
@@ -235,33 +347,28 @@ class BdAnchorNav extends LitElement {
235
347
  if (event?.preventDefault) event.preventDefault();
236
348
  const el = getTarget();
237
349
  if (!el) return;
238
- const offset = this._stickyOffsetPx();
239
- const rect = el.getBoundingClientRect();
240
- const scrollTop = window.scrollY ?? window.pageYOffset;
241
- const elementY = rect.top + scrollTop;
242
- const prefersReduced =
243
- typeof window !== "undefined" &&
350
+ const offset = this._stickyOffsetPx();
351
+ const rect = el.getBoundingClientRect();
352
+ const scrollTop = window.scrollY ?? window.pageYOffset;
353
+ const elementY = rect.top + scrollTop;
354
+ const prefersReduced = typeof window !== "undefined" &&
244
355
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;
245
- window.scrollTo({
246
- top : elementY - offset,
247
- behavior: prefersReduced ? "auto" : "smooth"
248
- });
356
+ window.scrollTo({ top: elementY - offset, behavior: prefersReduced ? "auto" : "smooth" });
249
357
  }
250
358
 
251
- handleClick(event, id) {
359
+ handleClick(event, id, href) {
252
360
  event.preventDefault();
253
361
  this._cancelScrollSpyUnlock();
254
362
  this._programmaticScrollTargetId = id;
255
- this.activeId = id;
256
-
257
- const sectionEl = document.getElementById(`${id}-section`);
363
+ this.activeId = id;
364
+ const sectionId = (href || "").replace(/^#/, "") || `${id}-section`;
365
+ const sectionEl = document.getElementById(sectionId);
258
366
  if (sectionEl) {
259
367
  this._scrollToY(event, () => sectionEl);
260
368
  this._scheduleScrollSpyUnlock();
261
369
  } else {
262
370
  this._programmaticScrollTargetId = null;
263
371
  }
264
-
265
372
  this.requestUpdate();
266
373
  }
267
374
 
@@ -272,54 +379,51 @@ class BdAnchorNav extends LitElement {
272
379
  this.requestUpdate();
273
380
  }
274
381
 
275
- _selectFromDropdown(e, id) {
276
- this.handleClick(e, id);
382
+ _selectFromDropdown(e, id, href) {
383
+ this.handleClick(e, id, href);
277
384
  this._dropdownOpen = false;
278
385
  this.requestUpdate();
279
386
  }
280
387
 
281
- scrollToPricing(event) {
282
- this._scrollToY(event, () => document.getElementById("section-pricing"));
283
- }
284
-
285
- _activeLabel(items) {
286
- const m = /^anchor-(\d+)$/.exec(this.activeId || "");
287
- const n = m ? parseInt(m[1], 10) : 1;
288
- const idx = Math.min(Math.max(n - 1, 0), Math.max(0, items.length - 1));
289
- return items[idx]?.getAttribute("title") || items[0]?.getAttribute("title") || "Section";
388
+ _activeLabel(resolved) {
389
+ const m = /^anchor-(\d+)$/.exec(this.activeId || "");
390
+ const n = m ? parseInt(m[1], 10) : 1;
391
+ const idx = Math.min(Math.max(n - 1, 0), Math.max(0, resolved.length - 1));
392
+ return resolved[idx]?.title || resolved[0]?.title || "Section";
290
393
  }
291
394
 
292
395
  render() {
293
- const items = Array.from(this.children).filter(
294
- (el) => el.tagName === "BD-ANCHOR-NAV-ITEM"
295
- );
396
+ const resolved = this._resolvedItems();
397
+ const dropdownLabel = this._activeLabel(resolved);
296
398
 
297
399
  const linkRow = (cls) => html`
298
400
  <div class="${cls}">
299
- ${items.map((item, index) => {
300
- const id = `anchor-${index + 1}`;
401
+ ${resolved.map(({ id, title, href }) => {
301
402
  const isActive = this.activeId === id;
302
403
  return html`
303
- <a
304
- href="#${id}-section"
305
- class="${isActive ? "active" : ""}"
404
+ <bd-link
405
+ href="${href}"
406
+ kind="${isActive ? "primary" : "secondary"}"
407
+ no-underline
408
+ class="anchor-link ${isActive ? "active" : ""}"
409
+ color="var(--color-neutral-900)"
410
+ ?active=${isActive}
306
411
  aria-current=${isActive ? "true" : nothing}
307
- @click=${(e) => this.handleClick(e, id)}
308
- >
309
- ${item.getAttribute("title")}
310
- </a>
412
+ @click=${(e) => this.handleClick(e, id, href)}
413
+ >${title}</bd-link>
311
414
  `;
312
415
  })}
313
416
  </div>
314
417
  `;
315
418
 
316
- const dropdownLabel = this._activeLabel(items);
419
+ const innerStyle = this.maxWidth ? `max-width: ${this.maxWidth};` : '';
317
420
 
318
421
  return html`
319
422
  <nav aria-label=${this.navLabel}>
320
423
  <div
321
424
  class="bd-anchor-nav__inner"
322
425
  part="inner"
426
+ style="${innerStyle}"
323
427
  ?data-mobile-cta-visible=${this._mobileCtaRevealed}
324
428
  >
325
429
  ${linkRow("anchor-links anchor-links--desktop")}
@@ -336,13 +440,7 @@ class BdAnchorNav extends LitElement {
336
440
  <span class="bd-anchor-nav__dropdown-label">${dropdownLabel}</span>
337
441
  <span class="bd-anchor-nav__dropdown-chevron" aria-hidden="true">
338
442
  <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
339
- <path
340
- d="M5 8l5 5 5-5"
341
- stroke="currentColor"
342
- stroke-width="2"
343
- stroke-linecap="round"
344
- stroke-linejoin="round"
345
- />
443
+ <path d="M5 8l5 5 5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
346
444
  </svg>
347
445
  </span>
348
446
  </button>
@@ -354,22 +452,17 @@ class BdAnchorNav extends LitElement {
354
452
  ?hidden=${!this._dropdownOpen}
355
453
  >
356
454
  <ul class="bd-anchor-nav__dropdown-list" role="presentation">
357
- ${items.map((item, index) => {
358
- const id = `anchor-${index + 1}`;
455
+ ${resolved.map(({ id, title, href }) => {
359
456
  const isActive = this.activeId === id;
360
457
  return html`
361
458
  <li role="presentation">
362
459
  <button
363
460
  type="button"
364
461
  role="option"
365
- class="bd-anchor-nav__dropdown-option ${isActive
366
- ? "bd-anchor-nav__dropdown-option--active"
367
- : ""}"
462
+ class="bd-anchor-nav__dropdown-option ${isActive ? "bd-anchor-nav__dropdown-option--active" : ""}"
368
463
  aria-selected=${isActive ? "true" : "false"}
369
- @click=${(e) => this._selectFromDropdown(e, id)}
370
- >
371
- ${item.getAttribute("title")}
372
- </button>
464
+ @click=${(e) => this._selectFromDropdown(e, id, href)}
465
+ >${title}</button>
373
466
  </li>
374
467
  `;
375
468
  })}
@@ -378,14 +471,7 @@ class BdAnchorNav extends LitElement {
378
471
  </div>
379
472
 
380
473
  <div class="bd-cta">
381
- <button
382
- type="button"
383
- class="bd-anchor-nav__cta"
384
- part="buy-button"
385
- @click=${(e) => this.scrollToPricing(e)}
386
- >
387
- Buy now
388
- </button>
474
+ <slot name="cta"></slot>
389
475
  </div>
390
476
  </div>
391
477
  </nav>
@@ -394,4 +480,4 @@ class BdAnchorNav extends LitElement {
394
480
  }
395
481
 
396
482
  customElements.define("bd-anchor-nav-item", BdAnchorNavItem);
397
- customElements.define("bd-anchor-nav", BdAnchorNav);
483
+ customElements.define("bd-anchor-nav", BdAnchorNav);