@repobit/dex-system-design 0.23.10 → 0.23.12

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,8 +1,20 @@
1
- import { LitElement, html } from "lit";
1
+ import { LitElement, html, nothing } from "lit";
2
2
  import { tokens } from "../../tokens/tokens.js";
3
- import "../Button/Button.js";
4
3
  import { anchorNavStyles } from "./anchor-nav.css.js";
5
4
 
5
+ /** Parse token length from computed style (e.g. `8rem` → px). */
6
+ function parseLengthToPx(value) {
7
+ const s = (value || "").trim();
8
+ if (!s) return 0;
9
+ if (s.endsWith("px")) return parseFloat(s);
10
+ if (s.endsWith("rem")) {
11
+ const root =
12
+ parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
13
+ return parseFloat(s) * root;
14
+ }
15
+ return parseFloat(s) || 0;
16
+ }
17
+
6
18
  class BdAnchorNavItem extends HTMLElement {
7
19
  constructor() {
8
20
  super();
@@ -14,67 +26,267 @@ class BdAnchorNavItem extends HTMLElement {
14
26
  }
15
27
 
16
28
  render() {
17
- this.shadowRoot.innerHTML = `
18
- <nav>
19
- <slot></slot>
20
- </nav>
21
- `;
29
+ this.shadowRoot.innerHTML = `<slot></slot>`;
22
30
  }
23
31
  }
24
32
 
25
-
26
33
  class BdAnchorNav extends LitElement {
34
+ static properties = {
35
+ activeId : { type: String, state: true },
36
+ /** Landmark label for the outer `<nav>` (screen readers). */
37
+ navLabel : { type: String, attribute: "aria-label" },
38
+ /** Mobile dropdown open state */
39
+ _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 }
42
+ };
43
+
27
44
  static styles = [tokens, anchorNavStyles];
28
45
 
29
46
  constructor() {
30
47
  super();
31
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)
53
+ .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 */
59
+ this._programmaticScrollTargetId = null;
60
+ this._unlockScrollSpyTimer = null;
61
+ this._scrollEndUnlockHandler = null;
62
+ }
63
+
64
+ connectedCallback() {
65
+ super.connectedCallback();
66
+ window.addEventListener("scroll", this._onScrollSpy, { passive: true });
67
+ window.addEventListener("resize", this._onScrollSpy, { passive: true });
68
+ document.addEventListener("pointerdown", this._onDocumentPointerDown, true);
69
+ document.addEventListener("keydown", this._onKeydown, true);
70
+ queueMicrotask(() => this._runScrollSpy());
71
+ }
72
+
73
+ disconnectedCallback() {
74
+ super.disconnectedCallback();
75
+ this._cancelScrollSpyUnlock();
76
+ window.removeEventListener("scroll", this._onScrollSpy);
77
+ window.removeEventListener("resize", this._onScrollSpy);
78
+ document.removeEventListener("pointerdown", this._onDocumentPointerDown, true);
79
+ document.removeEventListener("keydown", this._onKeydown, true);
80
+ }
81
+
82
+ _cancelScrollSpyUnlock() {
83
+ if (this._unlockScrollSpyTimer != null) {
84
+ clearTimeout(this._unlockScrollSpyTimer);
85
+ this._unlockScrollSpyTimer = null;
86
+ }
87
+ if (this._scrollEndUnlockHandler) {
88
+ window.removeEventListener("scrollend", this._scrollEndUnlockHandler);
89
+ this._scrollEndUnlockHandler = null;
90
+ }
91
+ }
92
+
93
+ /** After in-nav navigation, ignore scrollspy until scroll finishes (avoids flashing 2→3→4). */
94
+ _scheduleScrollSpyUnlock() {
95
+ this._cancelScrollSpyUnlock();
96
+ const onScrollEnd = () => {
97
+ if (this._unlockScrollSpyTimer != null) {
98
+ clearTimeout(this._unlockScrollSpyTimer);
99
+ this._unlockScrollSpyTimer = null;
100
+ }
101
+ window.removeEventListener("scrollend", onScrollEnd);
102
+ this._scrollEndUnlockHandler = null;
103
+ this._programmaticScrollTargetId = null;
104
+ this._runScrollSpy();
105
+ };
106
+ this._scrollEndUnlockHandler = onScrollEnd;
107
+ window.addEventListener("scrollend", onScrollEnd, { passive: true });
108
+ this._unlockScrollSpyTimer = window.setTimeout(() => {
109
+ this._unlockScrollSpyTimer = null;
110
+ if (this._scrollEndUnlockHandler) {
111
+ window.removeEventListener("scrollend", this._scrollEndUnlockHandler);
112
+ this._scrollEndUnlockHandler = null;
113
+ }
114
+ if (this._programmaticScrollTargetId) {
115
+ this._programmaticScrollTargetId = null;
116
+ this._runScrollSpy();
117
+ }
118
+ }, 1000);
119
+ }
120
+
121
+ _onKeydown(e) {
122
+ if (e.key === "Escape" && this._dropdownOpen) {
123
+ this._dropdownOpen = false;
124
+ this.requestUpdate();
125
+ }
126
+ }
127
+
128
+ _onDocumentPointerDown(e) {
129
+ if (!this._dropdownOpen) return;
130
+ const path = e.composedPath();
131
+ if (path.includes(this)) return;
132
+ this._dropdownOpen = false;
133
+ this.requestUpdate();
134
+ }
135
+
136
+ _onScrollSpy() {
137
+ if (this._scrollPending) return;
138
+ this._scrollPending = true;
139
+ requestAnimationFrame(() => {
140
+ this._scrollPending = false;
141
+ this._runScrollSpy();
142
+ });
143
+ }
144
+
145
+ _runScrollSpy() {
146
+ const count = this._navItemCount();
147
+ let next = this.activeId;
148
+ if (count && !this._programmaticScrollTargetId) {
149
+ const y = window.scrollY + this._stickyOffsetPx() + 2;
150
+ next = "anchor-1";
151
+ for (let i = 0; i < count; i++) {
152
+ const el = document.getElementById(`anchor-${i + 1}-section`);
153
+ if (!el) continue;
154
+ const top = el.getBoundingClientRect().top + window.scrollY;
155
+ if (top <= y) next = `anchor-${i + 1}`;
156
+ }
157
+ }
158
+
159
+ const isMobile = window.matchMedia("(max-width: 768px)").matches;
160
+ const first = document.getElementById("anchor-1-section");
161
+ 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
+ }
170
+
171
+ if (this.activeId !== next || this._mobileCtaRevealed !== nextCta) {
172
+ this.activeId = next;
173
+ this._mobileCtaRevealed = nextCta;
174
+ this.requestUpdate();
175
+ }
176
+ }
177
+
178
+ _navItemCount() {
179
+ return Array.from(this.children).filter(
180
+ (el) => el.tagName === "BD-ANCHOR-NAV-ITEM"
181
+ ).length;
32
182
  }
33
183
 
34
184
  firstUpdated() {
35
- const firstItem = this.querySelector("bd-anchor-nav-item");
36
- if (firstItem) {
37
- this.activeId = firstItem.id;
185
+ const n = this._navItemCount();
186
+ if (n) {
187
+ this.activeId = "anchor-1";
38
188
  this.requestUpdate();
39
189
  }
190
+ queueMicrotask(() => this._runScrollSpy());
191
+ }
192
+
193
+ updated(changedProperties) {
194
+ super.updated(changedProperties);
195
+ if (!changedProperties.has("activeId")) return;
196
+ const prev = changedProperties.get("activeId");
197
+ if (prev === undefined || prev === "") return;
198
+ if (prev === this.activeId) return;
199
+ queueMicrotask(() => this._playMobileDropdownLabelSwap());
200
+ }
201
+
202
+ /** Mobile dropdown title: subtle fade + slide when scrollspy (or tap) changes section. */
203
+ _playMobileDropdownLabelSwap() {
204
+ if (typeof window === "undefined") return;
205
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
206
+ if (!window.matchMedia("(max-width: 768px)").matches) return;
207
+ const el = this.renderRoot?.querySelector(".bd-anchor-nav__dropdown-label");
208
+ if (!el) return;
209
+ el.classList.remove("bd-anchor-nav__dropdown-label--swap");
210
+ void el.offsetWidth;
211
+ el.classList.add("bd-anchor-nav__dropdown-label--swap");
212
+ el.addEventListener(
213
+ "animationend",
214
+ () => el.classList.remove("bd-anchor-nav__dropdown-label--swap"),
215
+ { once: true }
216
+ );
217
+ }
218
+
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
+ _stickyOffsetPx() {
224
+ const barPx = this.offsetHeight;
225
+ const insetRaw = getComputedStyle(this).getPropertyValue("--spacing-8")
226
+ .trim();
227
+ const insetPx = parseLengthToPx(insetRaw) || 8;
228
+ if (barPx > 0) return barPx + insetPx;
229
+ const fallback = getComputedStyle(this).getPropertyValue("--spacing-128")
230
+ .trim();
231
+ return parseLengthToPx(fallback) || 128;
232
+ }
233
+
234
+ _scrollToY(event, getTarget) {
235
+ if (event?.preventDefault) event.preventDefault();
236
+ const el = getTarget();
237
+ 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" &&
244
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
245
+ window.scrollTo({
246
+ top : elementY - offset,
247
+ behavior: prefersReduced ? "auto" : "smooth"
248
+ });
40
249
  }
41
250
 
42
251
  handleClick(event, id) {
43
252
  event.preventDefault();
253
+ this._cancelScrollSpyUnlock();
254
+ this._programmaticScrollTargetId = id;
44
255
  this.activeId = id;
45
256
 
46
- const sectionEl = document.querySelector(`#${id}-section`);
257
+ const sectionEl = document.getElementById(`${id}-section`);
47
258
  if (sectionEl) {
48
- const offset = 120;
49
- const rect = sectionEl.getBoundingClientRect();
50
- const scrollTop = window.pageYOffset;
51
- const elementY = rect.top + scrollTop;
52
- const offsetY = elementY - offset;
53
-
54
- window.scrollTo({
55
- top : offsetY,
56
- behavior: "smooth"
57
- });
259
+ this._scrollToY(event, () => sectionEl);
260
+ this._scheduleScrollSpyUnlock();
261
+ } else {
262
+ this._programmaticScrollTargetId = null;
58
263
  }
59
264
 
60
265
  this.requestUpdate();
61
266
  }
62
267
 
268
+ _toggleDropdown(e) {
269
+ e.preventDefault();
270
+ e.stopPropagation();
271
+ this._dropdownOpen = !this._dropdownOpen;
272
+ this.requestUpdate();
273
+ }
63
274
 
275
+ _selectFromDropdown(e, id) {
276
+ this.handleClick(e, id);
277
+ this._dropdownOpen = false;
278
+ this.requestUpdate();
279
+ }
64
280
 
65
281
  scrollToPricing(event) {
66
- event.preventDefault();
67
- const pricingSection = document.querySelector("#section-pricing");
68
- if (pricingSection) {
69
- const offset = 120;
70
- const elementY = pricingSection.getBoundingClientRect().top + window.pageYOffset;
71
- const offsetY = elementY - offset;
72
-
73
- window.scrollTo({
74
- top : offsetY,
75
- behavior: "smooth"
76
- });
77
- }
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";
78
290
  }
79
291
 
80
292
  render() {
@@ -82,39 +294,102 @@ class BdAnchorNav extends LitElement {
82
294
  (el) => el.tagName === "BD-ANCHOR-NAV-ITEM"
83
295
  );
84
296
 
297
+ const linkRow = (cls) => html`
298
+ <div class="${cls}">
299
+ ${items.map((item, index) => {
300
+ const id = `anchor-${index + 1}`;
301
+ const isActive = this.activeId === id;
302
+ return html`
303
+ <a
304
+ href="#${id}-section"
305
+ class="${isActive ? "active" : ""}"
306
+ aria-current=${isActive ? "true" : nothing}
307
+ @click=${(e) => this.handleClick(e, id)}
308
+ >
309
+ ${item.getAttribute("title")}
310
+ </a>
311
+ `;
312
+ })}
313
+ </div>
314
+ `;
315
+
316
+ const dropdownLabel = this._activeLabel(items);
317
+
85
318
  return html`
86
- <nav>
87
- <div class="anchor-links">
88
- ${items.map((item, index) => {
89
- const id = `anchor-${index + 1}`;
90
- return html`
91
- <a
92
- href="#${id}"
93
- class="${this.activeId === id ? "active" : ""}"
94
- @click=${(e) => this.handleClick(e, id)}
95
-
96
- >
97
- ${item.getAttribute("title")}
98
- </a>
99
- `;
100
- })}
319
+ <nav aria-label=${this.navLabel}>
320
+ <div
321
+ class="bd-anchor-nav__inner"
322
+ part="inner"
323
+ ?data-mobile-cta-visible=${this._mobileCtaRevealed}
324
+ >
325
+ ${linkRow("anchor-links anchor-links--desktop")}
326
+
327
+ <div class="bd-anchor-nav__dropdown" part="dropdown">
328
+ <button
329
+ type="button"
330
+ class="bd-anchor-nav__dropdown-toggle"
331
+ aria-expanded=${this._dropdownOpen ? "true" : "false"}
332
+ aria-controls=${this._panelId}
333
+ aria-haspopup="listbox"
334
+ @click=${this._toggleDropdown}
335
+ >
336
+ <span class="bd-anchor-nav__dropdown-label">${dropdownLabel}</span>
337
+ <span class="bd-anchor-nav__dropdown-chevron" aria-hidden="true">
338
+ <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
+ />
346
+ </svg>
347
+ </span>
348
+ </button>
349
+ <div
350
+ class="bd-anchor-nav__dropdown-panel"
351
+ id=${this._panelId}
352
+ role="listbox"
353
+ aria-label=${this.navLabel}
354
+ ?hidden=${!this._dropdownOpen}
355
+ >
356
+ <ul class="bd-anchor-nav__dropdown-list" role="presentation">
357
+ ${items.map((item, index) => {
358
+ const id = `anchor-${index + 1}`;
359
+ const isActive = this.activeId === id;
360
+ return html`
361
+ <li role="presentation">
362
+ <button
363
+ type="button"
364
+ role="option"
365
+ class="bd-anchor-nav__dropdown-option ${isActive
366
+ ? "bd-anchor-nav__dropdown-option--active"
367
+ : ""}"
368
+ aria-selected=${isActive ? "true" : "false"}
369
+ @click=${(e) => this._selectFromDropdown(e, id)}
370
+ >
371
+ ${item.getAttribute("title")}
372
+ </button>
373
+ </li>
374
+ `;
375
+ })}
376
+ </ul>
377
+ </div>
101
378
  </div>
102
379
 
103
380
  <div class="bd-cta">
104
- <bd-button
105
- label="Buy now"
106
- kind="danger"
107
- size="md"
381
+ <button
382
+ type="button"
383
+ class="bd-anchor-nav__cta"
108
384
  part="buy-button"
109
- font-size="18px"
110
- font-weight="600"
111
385
  @click=${(e) => this.scrollToPricing(e)}
112
386
  >
113
387
  Buy now
114
- </bd-button>
388
+ </button>
115
389
  </div>
116
- </nav>
117
- `;
390
+ </div>
391
+ </nav>
392
+ `;
118
393
  }
119
394
  }
120
395
 
@@ -1,8 +1,13 @@
1
- import { LitElement, html } from "lit";
1
+ import { LitElement, css, html } from "lit";
2
2
  import { tokens } from "../../tokens/tokens.js";
3
3
  import "../awards/awards-icon.js";
4
4
  import { awardsCSS } from "./awards.css.js";
5
5
 
6
+ const awardslightOverride = css`
7
+ .bd-awards-root {
8
+ gap: 0;
9
+ }
10
+ `;
6
11
  export class AwardsSection extends LitElement {
7
12
  static properties = {
8
13
  tagline : { type: String },
@@ -16,7 +21,8 @@ export class AwardsSection extends LitElement {
16
21
  _featureNarrow: { state: true }
17
22
  };
18
23
 
19
- static styles = [tokens, awardsCSS];
24
+
25
+ static styles = [tokens, awardsCSS, awardslightOverride];
20
26
 
21
27
  /** @type {MediaQueryList | null} */
22
28
  _mql599 = null;