@pnlight/sdk-react 0.1.0 → 0.2.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.
@@ -60,7 +60,73 @@ const PREPEND_LIST_PROPS = [
60
60
  "slide",
61
61
  "shows_scroll_indicator"
62
62
  ];
63
+ const PROGRESS_BAR_PROPS = [
64
+ "instance_id",
65
+ "progress",
66
+ "progress_variable",
67
+ "initial_progress",
68
+ "indeterminate",
69
+ "track_color",
70
+ "track_height",
71
+ "fill_color",
72
+ "fill_color_end",
73
+ "corner_radius",
74
+ "fill_inset",
75
+ "animation_duration",
76
+ "indeterminate_duration",
77
+ "indeterminate_band_width",
78
+ "reduced_motion",
79
+ "accessibility_label"
80
+ ];
81
+ const ANIMATED_NUMBER_PROPS = [
82
+ "instance_id",
83
+ "value",
84
+ "value_variable",
85
+ "initial_value",
86
+ "font_size",
87
+ "font_weight",
88
+ "text_color",
89
+ "text_alignment",
90
+ "decimals",
91
+ "min_integer_digits",
92
+ "grouping",
93
+ "monospaced_digits",
94
+ "prefix",
95
+ "suffix",
96
+ "animation_duration",
97
+ "curve",
98
+ "reduced_motion",
99
+ "accessibility_label"
100
+ ];
63
101
  const prependListSnapshots = new WeakMap();
102
+ const progressSnapshots = new WeakMap();
103
+ const numberSnapshots = new WeakMap();
104
+ /**
105
+ * Discards state retained for a direct document mounted into a persistent host.
106
+ * Flow-route stores are keyed by their disposable route elements and become
107
+ * unreachable when the flow is destroyed.
108
+ */
109
+ export function clearPNLightComponentSnapshots(host) {
110
+ prependListSnapshots.delete(host);
111
+ progressSnapshots.delete(host);
112
+ numberSnapshots.delete(host);
113
+ }
114
+ /**
115
+ * Per-component state that has to survive DivKit replacing a custom element,
116
+ * scoped to the flow route (or document) the element belongs to.
117
+ */
118
+ function snapshotStore(snapshots, element) {
119
+ const scope = element.closest(".pnlight-flow-route")
120
+ ?? element.closest(".pnlight-remote-ui")
121
+ ?? element.parentElement
122
+ ?? element;
123
+ let store = snapshots.get(scope);
124
+ if (!store) {
125
+ store = new Map();
126
+ snapshots.set(scope, store);
127
+ }
128
+ return store;
129
+ }
64
130
  function parseBoolean(value, fallback = false) {
65
131
  if (typeof value === "boolean")
66
132
  return value;
@@ -80,6 +146,34 @@ function parseNumber(value, fallback) {
80
146
  const parsed = typeof value === "number" ? value : Number(value);
81
147
  return Number.isFinite(parsed) ? parsed : fallback;
82
148
  }
149
+ function clampUnit(value) {
150
+ return Math.min(1, Math.max(0, value));
151
+ }
152
+ function prefersReducedMotion() {
153
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
154
+ }
155
+ function parseCurve(value, fallback = "ease_out") {
156
+ switch (String(value ?? "").trim().toLowerCase()) {
157
+ case "linear": return "linear";
158
+ case "ease_in": return "ease_in";
159
+ case "ease_out": return "ease_out";
160
+ case "ease_in_out": return "ease_in_out";
161
+ default: return fallback;
162
+ }
163
+ }
164
+ /** Mirrors the native easing curves so both renderers count identically. */
165
+ function easingFraction(curve, time) {
166
+ const clamped = clampUnit(time);
167
+ switch (curve) {
168
+ case "linear": return clamped;
169
+ case "ease_in": return clamped * clamped;
170
+ case "ease_out": return 1 - (1 - clamped) * (1 - clamped);
171
+ default:
172
+ return clamped < 0.5
173
+ ? 2 * clamped * clamped
174
+ : 1 - ((-2 * clamped + 2) ** 2) / 2;
175
+ }
176
+ }
83
177
  function parseObject(value) {
84
178
  if (value && typeof value === "object" && !Array.isArray(value)) {
85
179
  return value;
@@ -192,6 +286,7 @@ class PNLightElement extends HTMLElement {
192
286
  values = new Map();
193
287
  context;
194
288
  scheduled = false;
289
+ variableUnsubscribe;
195
290
  divKitApiCallback(context) {
196
291
  this.context = context;
197
292
  }
@@ -209,7 +304,14 @@ class PNLightElement extends HTMLElement {
209
304
  });
210
305
  }
211
306
  }
212
- value(name) {
307
+ /**
308
+ * Reads a prop, falling back to the matching attribute.
309
+ *
310
+ * Named `prop` rather than `value` because `defineProps` installs each prop
311
+ * as an own property on the element, and a component with a `value` prop
312
+ * would otherwise shadow this method.
313
+ */
314
+ prop(name) {
213
315
  return this.values.has(name)
214
316
  ? this.values.get(name)
215
317
  : this.getAttribute(name);
@@ -224,9 +326,34 @@ class PNLightElement extends HTMLElement {
224
326
  this.render();
225
327
  });
226
328
  }
329
+ /**
330
+ * Binds a `<name>_variable` prop to the prop it drives.
331
+ *
332
+ * DivKit Web does not re-evaluate expressions embedded in `custom_props`, so
333
+ * a reactive component subscribes to the named card variable itself.
334
+ */
335
+ bindVariableProp(variableProp, valueProp) {
336
+ this.unbindVariableProp();
337
+ const name = String(this.prop(variableProp) ?? "").trim();
338
+ if (!name || !this.context?.variables)
339
+ return;
340
+ const variable = this.context.variables.get(name);
341
+ if (!variable)
342
+ return;
343
+ const update = (value) => {
344
+ this.values.set(valueProp, Number(value));
345
+ this.scheduleRender();
346
+ };
347
+ update(variable.getValue());
348
+ this.variableUnsubscribe = variable.subscribe(update);
349
+ }
350
+ unbindVariableProp() {
351
+ this.variableUnsubscribe?.();
352
+ this.variableUnsubscribe = undefined;
353
+ }
227
354
  fireAction() {
228
- const url = this.value("url");
229
- const logId = this.value("log_id");
355
+ const url = this.prop("url");
356
+ const logId = this.prop("log_id");
230
357
  const action = {
231
358
  log_id: typeof logId === "string" && logId
232
359
  ? logId
@@ -267,7 +394,7 @@ class PNLightButtonElement extends PNLightElement {
267
394
  this.scheduleRender();
268
395
  }
269
396
  shimmerConfig(defaultEnabled) {
270
- const raw = this.value("shimmer");
397
+ const raw = this.prop("shimmer");
271
398
  const object = parseObject(raw);
272
399
  if (!object) {
273
400
  return {
@@ -290,7 +417,7 @@ class PNLightButtonElement extends PNLightElement {
290
417
  }
291
418
  bounceConfig() {
292
419
  const defaultIdle = this.kind === "cta";
293
- const raw = this.value("bounce");
420
+ const raw = this.prop("bounce");
294
421
  if (!parseObject(raw)) {
295
422
  const enabled = raw == null ? null : parseBoolean(raw);
296
423
  return {
@@ -322,27 +449,27 @@ class PNLightButtonElement extends PNLightElement {
322
449
  }
323
450
  render() {
324
451
  const isIcon = this.kind === "icon";
325
- const title = String(this.value("title") ?? "");
326
- const icon = this.value("icon");
327
- const iconSize = parseNumber(this.value("icon_size"), 20);
328
- const loading = parseBoolean(this.value("loading"));
329
- const disabled = parseBoolean(this.value("disabled"));
452
+ const title = String(this.prop("title") ?? "");
453
+ const icon = this.prop("icon");
454
+ const iconSize = parseNumber(this.prop("icon_size"), 20);
455
+ const loading = parseBoolean(this.prop("loading"));
456
+ const disabled = parseBoolean(this.prop("disabled"));
330
457
  const inactive = loading || disabled;
331
- const titleColor = cssColor(this.value(disabled ? "disabled_title_color" : "title_color"), isIcon ? "#111827" : "#ffffff");
332
- const iconColor = cssColor(this.value("icon_color"), titleColor);
458
+ const titleColor = cssColor(this.prop(disabled ? "disabled_title_color" : "title_color"), isIcon ? "#111827" : "#ffffff");
459
+ const iconColor = cssColor(this.prop("icon_color"), titleColor);
333
460
  const defaultBackground = isIcon ? "rgba(118, 118, 128, 0.18)" : "#007aff";
334
- const background = cssColor(this.value(disabled ? "disabled_background_color" : "background_color"), defaultBackground);
335
- const gradientEnd = this.value("background_color_end");
461
+ const background = cssColor(this.prop(disabled ? "disabled_background_color" : "background_color"), defaultBackground);
462
+ const gradientEnd = this.prop("background_color_end");
336
463
  const shimmer = this.shimmerConfig(!isIcon);
337
464
  const bounce = this.bounceConfig();
338
- const explicitRadius = this.value("corner_radius");
465
+ const explicitRadius = this.prop("corner_radius");
339
466
  const radius = isIcon && explicitRadius == null
340
467
  ? "999px"
341
468
  : `${parseNumber(explicitRadius, 14)}px`;
342
- const glassValue = this.value("glass");
469
+ const glassValue = this.prop("glass");
343
470
  const glassObject = parseObject(glassValue);
344
471
  const glassEnabled = glassValue == null
345
- ? isIcon && this.value("background_color") == null
472
+ ? isIcon && this.prop("background_color") == null
346
473
  : glassObject
347
474
  ? parseBoolean(glassObject.enabled, true)
348
475
  : parseBoolean(glassValue);
@@ -352,8 +479,8 @@ class PNLightButtonElement extends PNLightElement {
352
479
  : glassEnabled
353
480
  ? glassTint
354
481
  : background;
355
- const disabledAlpha = parseNumber(this.value("disabled_alpha"), 0.45);
356
- const accessibilityLabel = String(this.value("accessibility_label") ?? (loading ? "Loading" : title || icon || "Button"));
482
+ const disabledAlpha = parseNumber(this.prop("disabled_alpha"), 0.45);
483
+ const accessibilityLabel = String(this.prop("accessibility_label") ?? (loading ? "Loading" : title || icon || "Button"));
357
484
  this.root.innerHTML = `
358
485
  <style>
359
486
  :host {
@@ -383,8 +510,8 @@ class PNLightButtonElement extends PNLightElement {
383
510
  min-width: 0;
384
511
  align-items: center;
385
512
  justify-content: center;
386
- gap: ${parseNumber(this.value("icon_spacing"), 8)}px;
387
- padding: ${parseNumber(this.value("vertical_padding"), isIcon ? 12 : 16)}px ${parseNumber(this.value("horizontal_padding"), isIcon ? 12 : 24)}px;
513
+ gap: ${parseNumber(this.prop("icon_spacing"), 8)}px;
514
+ padding: ${parseNumber(this.prop("vertical_padding"), isIcon ? 12 : 16)}px ${parseNumber(this.prop("horizontal_padding"), isIcon ? 12 : 24)}px;
388
515
  overflow: hidden;
389
516
  border: ${glassEnabled ? "1px solid rgba(255,255,255,.38)" : "0"};
390
517
  border-radius: ${radius};
@@ -433,8 +560,8 @@ class PNLightButtonElement extends PNLightElement {
433
560
  overflow: hidden;
434
561
  text-overflow: ellipsis;
435
562
  white-space: nowrap;
436
- font-size: ${parseNumber(this.value("font_size"), 18)}px;
437
- font-weight: ${fontWeight(this.value("font_weight"))};
563
+ font-size: ${parseNumber(this.prop("font_size"), 18)}px;
564
+ font-weight: ${fontWeight(this.prop("font_weight"))};
438
565
  line-height: 1.1;
439
566
  }
440
567
  .icon {
@@ -466,10 +593,10 @@ class PNLightButtonElement extends PNLightElement {
466
593
  .spinner {
467
594
  position: relative;
468
595
  z-index: 2;
469
- width: ${this.value("loading_indicator_style") === "large" ? 25 : 20}px;
470
- height: ${this.value("loading_indicator_style") === "large" ? 25 : 20}px;
471
- border: 2.5px solid color-mix(in srgb, ${cssColor(this.value("loading_indicator_color"), titleColor)} 28%, transparent);
472
- border-top-color: ${cssColor(this.value("loading_indicator_color"), titleColor)};
596
+ width: ${this.prop("loading_indicator_style") === "large" ? 25 : 20}px;
597
+ height: ${this.prop("loading_indicator_style") === "large" ? 25 : 20}px;
598
+ border: 2.5px solid color-mix(in srgb, ${cssColor(this.prop("loading_indicator_color"), titleColor)} 28%, transparent);
599
+ border-top-color: ${cssColor(this.prop("loading_indicator_color"), titleColor)};
473
600
  border-radius: 50%;
474
601
  animation: pnlight-spin .72s linear infinite;
475
602
  }
@@ -504,7 +631,7 @@ class PNLightButtonElement extends PNLightElement {
504
631
  ${shimmer.enabled && !inactive ? '<span class="shimmer" aria-hidden="true"></span>' : ""}
505
632
  ${loading
506
633
  ? '<span class="spinner" aria-hidden="true"></span>'
507
- : `${icon ? `<span class="icon" aria-hidden="true">${iconMarkup(icon, iconSize, this.value("icon_weight"))}</span>` : ""}${title ? `<span class="label">${escapeHtml(title)}</span>` : ""}`}
634
+ : `${icon ? `<span class="icon" aria-hidden="true">${iconMarkup(icon, iconSize, this.prop("icon_weight"))}</span>` : ""}${title ? `<span class="label">${escapeHtml(title)}</span>` : ""}`}
508
635
  </button>
509
636
  </div>
510
637
  `;
@@ -546,10 +673,10 @@ class PNLightCircularLoader extends PNLightElement {
546
673
  this.scheduleRender();
547
674
  }
548
675
  render() {
549
- const large = this.value("style") === "large";
676
+ const large = this.prop("style") === "large";
550
677
  const size = large ? 36 : 22;
551
- const color = cssColor(this.value("color"), "#111827");
552
- const label = String(this.value("accessibility_label") ?? "Loading");
678
+ const color = cssColor(this.prop("color"), "#111827");
679
+ const label = String(this.prop("accessibility_label") ?? "Loading");
553
680
  this.root.innerHTML = `
554
681
  <style>
555
682
  :host {
@@ -585,7 +712,6 @@ class PNLightAnimatedPrependList extends PNLightElement {
585
712
  itemsSignature = "";
586
713
  styleSignature = "";
587
714
  initialized = false;
588
- unsubscribeCount;
589
715
  constructor() {
590
716
  super();
591
717
  this.defineProps(PREPEND_LIST_PROPS);
@@ -596,15 +722,14 @@ class PNLightAnimatedPrependList extends PNLightElement {
596
722
  }
597
723
  connectedCallback() {
598
724
  this.render();
599
- this.bindCountVariable();
725
+ this.bindVariableProp("count_variable", "count");
600
726
  }
601
727
  disconnectedCallback() {
602
- this.unsubscribeCount?.();
603
- this.unsubscribeCount = undefined;
728
+ this.unbindVariableProp();
604
729
  }
605
730
  divKitApiCallback(context) {
606
731
  super.divKitApiCallback(context);
607
- this.bindCountVariable();
732
+ this.bindVariableProp("count_variable", "count");
608
733
  }
609
734
  attributeChangedCallback(name, _oldValue, value) {
610
735
  this.values.set(name, value);
@@ -612,16 +737,16 @@ class PNLightAnimatedPrependList extends PNLightElement {
612
737
  }
613
738
  render() {
614
739
  const items = this.parsedItems();
615
- const count = Math.min(items.length, Math.max(0, Math.floor(parseNumber(this.value("count"), 0))));
616
- const instanceId = String(this.value("instance_id") ?? "pnlight.animated_prepend_list.default");
740
+ const count = Math.min(items.length, Math.max(0, Math.floor(parseNumber(this.prop("count"), 0))));
741
+ const instanceId = String(this.prop("instance_id") ?? "pnlight.animated_prepend_list.default");
617
742
  const itemsSignature = JSON.stringify(items);
618
743
  const styleSignature = this.currentStyleSignature();
619
744
  const instanceChanged = this.instanceId !== instanceId;
620
745
  const styleChanged = this.styleSignature !== styleSignature;
621
746
  const itemsChanged = this.itemsSignature !== itemsSignature;
622
- const snapshotStore = this.snapshotStore();
747
+ const store = snapshotStore(prependListSnapshots, this);
623
748
  const snapshot = instanceChanged
624
- ? snapshotStore.get(instanceId)
749
+ ? store.get(instanceId)
625
750
  : undefined;
626
751
  const canRestoreSnapshot = snapshot?.itemsSignature === itemsSignature &&
627
752
  snapshot.styleSignature === styleSignature;
@@ -633,7 +758,7 @@ class PNLightAnimatedPrependList extends PNLightElement {
633
758
  ? snapshot.displayedCount
634
759
  : 0;
635
760
  }
636
- const reducedMotion = parseBoolean(this.value("reduced_motion")) ||
761
+ const reducedMotion = parseBoolean(this.prop("reduced_motion")) ||
637
762
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;
638
763
  const canAnimateInPlace = this.initialized &&
639
764
  !instanceChanged &&
@@ -664,42 +789,14 @@ class PNLightAnimatedPrependList extends PNLightElement {
664
789
  this.displayedCount = count;
665
790
  this.itemsSignature = itemsSignature;
666
791
  this.styleSignature = styleSignature;
667
- snapshotStore.set(instanceId, {
792
+ store.set(instanceId, {
668
793
  displayedCount: count,
669
794
  itemsSignature,
670
795
  styleSignature
671
796
  });
672
797
  }
673
- bindCountVariable() {
674
- this.unsubscribeCount?.();
675
- this.unsubscribeCount = undefined;
676
- const name = String(this.value("count_variable") ?? "").trim();
677
- if (!name || !this.context?.variables)
678
- return;
679
- const variable = this.context.variables.get(name);
680
- if (!variable)
681
- return;
682
- const update = (value) => {
683
- this.values.set("count", Number(value));
684
- this.scheduleRender();
685
- };
686
- update(variable.getValue());
687
- this.unsubscribeCount = variable.subscribe(update);
688
- }
689
- snapshotStore() {
690
- const scope = this.closest(".pnlight-flow-route") ??
691
- this.closest(".pnlight-remote-ui") ??
692
- this.parentElement ??
693
- this;
694
- let store = prependListSnapshots.get(scope);
695
- if (!store) {
696
- store = new Map();
697
- prependListSnapshots.set(scope, store);
698
- }
699
- return store;
700
- }
701
798
  parsedItems() {
702
- return parseArray(this.value("items")).flatMap((raw) => {
799
+ return parseArray(this.prop("items")).flatMap((raw) => {
703
800
  const item = parseObject(raw);
704
801
  if (!item)
705
802
  return [];
@@ -720,19 +817,19 @@ class PNLightAnimatedPrependList extends PNLightElement {
720
817
  "items",
721
818
  "reduced_motion"
722
819
  ].includes(name))
723
- .map((name) => this.value(name)));
820
+ .map((name) => this.prop(name)));
724
821
  }
725
822
  installShell() {
726
- const showsIndicator = parseBoolean(this.value("shows_scroll_indicator"));
727
- const listHorizontalPadding = Math.max(0, parseNumber(this.value("list_horizontal_padding"), 4));
728
- const rowSpacing = Math.max(0, parseNumber(this.value("row_spacing"), 15));
729
- const bottomPadding = Math.max(0, parseNumber(this.value("bottom_padding"), 20));
730
- const rowHorizontalPadding = Math.max(0, parseNumber(this.value("row_horizontal_padding"), 16));
731
- const rowVerticalPadding = Math.max(0, parseNumber(this.value("row_vertical_padding"), 16));
732
- const iconSize = Math.max(0, parseNumber(this.value("icon_size"), 20));
733
- const iconTextSpacing = Math.max(0, parseNumber(this.value("icon_text_spacing"), 13));
734
- const textStatusSpacing = Math.max(0, parseNumber(this.value("text_status_spacing"), 13));
735
- const cornerRadius = Math.max(0, parseNumber(this.value("corner_radius"), 20));
823
+ const showsIndicator = parseBoolean(this.prop("shows_scroll_indicator"));
824
+ const listHorizontalPadding = Math.max(0, parseNumber(this.prop("list_horizontal_padding"), 4));
825
+ const rowSpacing = Math.max(0, parseNumber(this.prop("row_spacing"), 15));
826
+ const bottomPadding = Math.max(0, parseNumber(this.prop("bottom_padding"), 20));
827
+ const rowHorizontalPadding = Math.max(0, parseNumber(this.prop("row_horizontal_padding"), 16));
828
+ const rowVerticalPadding = Math.max(0, parseNumber(this.prop("row_vertical_padding"), 16));
829
+ const iconSize = Math.max(0, parseNumber(this.prop("icon_size"), 20));
830
+ const iconTextSpacing = Math.max(0, parseNumber(this.prop("icon_text_spacing"), 13));
831
+ const textStatusSpacing = Math.max(0, parseNumber(this.prop("text_status_spacing"), 13));
832
+ const cornerRadius = Math.max(0, parseNumber(this.prop("corner_radius"), 20));
736
833
  this.root.innerHTML = `
737
834
  <style>
738
835
  :host {
@@ -772,7 +869,7 @@ class PNLightAnimatedPrependList extends PNLightElement {
772
869
  padding: ${rowVerticalPadding}px ${rowHorizontalPadding}px;
773
870
  overflow: hidden;
774
871
  border-radius: ${cornerRadius}px;
775
- background: ${cssColor(this.value("card_background_color"), "#fefefe")};
872
+ background: ${cssColor(this.prop("card_background_color"), "#fefefe")};
776
873
  transform-origin: top center;
777
874
  }
778
875
  .icon {
@@ -792,21 +889,21 @@ class PNLightAnimatedPrependList extends PNLightElement {
792
889
  overflow-wrap: anywhere;
793
890
  }
794
891
  .title {
795
- color: ${cssColor(this.value("title_color"), "#1c1c1e")};
796
- font-size: ${Math.max(1, parseNumber(this.value("title_font_size"), 14))}px;
797
- font-weight: ${fontWeight(this.value("title_font_weight"), 700)};
892
+ color: ${cssColor(this.prop("title_color"), "#1c1c1e")};
893
+ font-size: ${Math.max(1, parseNumber(this.prop("title_font_size"), 14))}px;
894
+ font-weight: ${fontWeight(this.prop("title_font_weight"), 700)};
798
895
  }
799
896
  .body {
800
- color: ${cssColor(this.value("body_color"), "#3a3a3c")};
801
- font-size: ${Math.max(1, parseNumber(this.value("body_font_size"), 12))}px;
802
- font-weight: ${fontWeight(this.value("body_font_weight"), 300)};
897
+ color: ${cssColor(this.prop("body_color"), "#3a3a3c")};
898
+ font-size: ${Math.max(1, parseNumber(this.prop("body_font_size"), 12))}px;
899
+ font-weight: ${fontWeight(this.prop("body_font_weight"), 300)};
803
900
  }
804
901
  .status {
805
902
  flex: 0 0 auto;
806
903
  margin-left: ${textStatusSpacing}px;
807
- color: ${cssColor(this.value("status_color"), "#ff3b30")};
808
- font-size: ${Math.max(1, parseNumber(this.value("status_font_size"), 12))}px;
809
- font-weight: ${fontWeight(this.value("status_font_weight"), 600)};
904
+ color: ${cssColor(this.prop("status_color"), "#ff3b30")};
905
+ font-size: ${Math.max(1, parseNumber(this.prop("status_font_size"), 12))}px;
906
+ font-weight: ${fontWeight(this.prop("status_font_weight"), 600)};
810
907
  white-space: nowrap;
811
908
  }
812
909
  </style>
@@ -833,10 +930,10 @@ class PNLightAnimatedPrependList extends PNLightElement {
833
930
  const oldPositions = new Map(oldRows.map((row) => [row.dataset.index ?? "", row.getBoundingClientRect().top]));
834
931
  const row = this.createRow(item, index);
835
932
  this.list.prepend(row);
836
- const duration = Math.max(0, parseNumber(this.value("animation_duration"), 0.36) * 1000);
837
- const slideDistance = Math.max(0, parseNumber(this.value("slide_distance"), 8));
838
- const fades = parseBoolean(this.value("fade"), true);
839
- const slides = parseBoolean(this.value("slide"), true);
933
+ const duration = Math.max(0, parseNumber(this.prop("animation_duration"), 0.36) * 1000);
934
+ const slideDistance = Math.max(0, parseNumber(this.prop("slide_distance"), 8));
935
+ const fades = parseBoolean(this.prop("fade"), true);
936
+ const slides = parseBoolean(this.prop("slide"), true);
840
937
  const easing = "cubic-bezier(.4, 0, .2, 1)";
841
938
  if (duration > 0) {
842
939
  for (const existingRow of oldRows) {
@@ -859,7 +956,7 @@ class PNLightAnimatedPrependList extends PNLightElement {
859
956
  this.viewport?.scrollTo({ top: 0, behavior: "auto" });
860
957
  }
861
958
  createRow(item, index) {
862
- const status = String(this.value("status_text") ?? "");
959
+ const status = String(this.prop("status_text") ?? "");
863
960
  const row = document.createElement("div");
864
961
  row.className = "row";
865
962
  row.dataset.index = String(index);
@@ -876,6 +973,403 @@ class PNLightAnimatedPrependList extends PNLightElement {
876
973
  return row;
877
974
  }
878
975
  }
976
+ class PNLightProgressBar extends PNLightElement {
977
+ root;
978
+ track;
979
+ fill;
980
+ instanceId = "";
981
+ styleSignature = "";
982
+ displayedProgress = 0;
983
+ initialized = false;
984
+ progressSnapshotFrame;
985
+ constructor() {
986
+ super();
987
+ this.defineProps(PROGRESS_BAR_PROPS);
988
+ this.root = this.attachShadow({ mode: "open" });
989
+ }
990
+ static get observedAttributes() {
991
+ return [...PROGRESS_BAR_PROPS];
992
+ }
993
+ connectedCallback() {
994
+ this.render();
995
+ this.bindVariableProp("progress_variable", "progress");
996
+ }
997
+ disconnectedCallback() {
998
+ this.unbindVariableProp();
999
+ this.stopProgressSnapshot();
1000
+ }
1001
+ divKitApiCallback(context) {
1002
+ super.divKitApiCallback(context);
1003
+ this.bindVariableProp("progress_variable", "progress");
1004
+ }
1005
+ attributeChangedCallback(name, _oldValue, value) {
1006
+ this.values.set(name, value);
1007
+ this.scheduleRender();
1008
+ }
1009
+ render() {
1010
+ const instanceId = String(this.prop("instance_id") ?? "pnlight.progress_bar.default");
1011
+ const styleSignature = this.currentStyleSignature();
1012
+ const instanceChanged = this.instanceId !== instanceId;
1013
+ const styleChanged = this.styleSignature !== styleSignature;
1014
+ const store = snapshotStore(progressSnapshots, this);
1015
+ const target = clampUnit(parseNumber(this.prop("progress"), 0));
1016
+ if (!this.initialized || instanceChanged || styleChanged) {
1017
+ this.installShell();
1018
+ // A replaced element resumes where the previous one stopped, so DivKit
1019
+ // re-creating the custom element never replays the fill from zero.
1020
+ const restored = store.get(instanceId);
1021
+ const initial = this.prop("initial_progress");
1022
+ this.displayedProgress = restored
1023
+ ?? (initial == null ? target : clampUnit(parseNumber(initial, 0)));
1024
+ this.applyProgress(this.displayedProgress, false);
1025
+ }
1026
+ const reducedMotion = parseBoolean(this.prop("reduced_motion")) || prefersReducedMotion();
1027
+ this.applyProgress(target, !reducedMotion);
1028
+ // Persist the on-screen fill, not the destination. DivKit replaces this
1029
+ // element on remold; the next instance has to resume from here.
1030
+ this.displayedProgress = this.visualProgress();
1031
+ store.set(instanceId, this.displayedProgress);
1032
+ this.watchProgressSnapshot(store, instanceId, target);
1033
+ this.initialized = true;
1034
+ this.instanceId = instanceId;
1035
+ this.styleSignature = styleSignature;
1036
+ }
1037
+ currentStyleSignature() {
1038
+ return JSON.stringify(PROGRESS_BAR_PROPS
1039
+ .filter((name) => ![
1040
+ "instance_id",
1041
+ "progress",
1042
+ "progress_variable",
1043
+ "initial_progress"
1044
+ ].includes(name))
1045
+ .map((name) => this.prop(name)));
1046
+ }
1047
+ installShell() {
1048
+ const trackColor = cssColor(this.prop("track_color"), "rgba(120, 120, 128, 0.2)");
1049
+ const fillColor = cssColor(this.prop("fill_color"), "#007aff");
1050
+ const fillColorEnd = this.prop("fill_color_end");
1051
+ const fill = fillColorEnd == null
1052
+ ? fillColor
1053
+ : `linear-gradient(90deg, ${fillColor}, ${cssColor(fillColorEnd, fillColor)})`;
1054
+ const inset = Math.max(0, parseNumber(this.prop("fill_inset"), 0));
1055
+ const cornerRadius = this.prop("corner_radius");
1056
+ const radius = cornerRadius == null
1057
+ ? "9999px"
1058
+ : `${Math.max(0, parseNumber(cornerRadius, 0))}px`;
1059
+ const fillRadius = cornerRadius == null
1060
+ ? "9999px"
1061
+ : `${Math.max(0, parseNumber(cornerRadius, 0) - inset)}px`;
1062
+ const trackHeight = Math.max(1, parseNumber(this.prop("track_height"), 8));
1063
+ const duration = Math.max(0, parseNumber(this.prop("animation_duration"), 0.3)) * 1000;
1064
+ const indeterminate = parseBoolean(this.prop("indeterminate"));
1065
+ const band = Math.min(1, Math.max(0.05, parseNumber(this.prop("indeterminate_band_width"), 0.3)));
1066
+ const indeterminateDuration = Math.max(0.1, parseNumber(this.prop("indeterminate_duration"), 1.1)) * 1000;
1067
+ const holdsBandStill = parseBoolean(this.prop("reduced_motion"));
1068
+ const label = String(this.prop("accessibility_label") ?? "Progress");
1069
+ this.root.innerHTML = `
1070
+ <style>
1071
+ :host {
1072
+ display: block;
1073
+ width: 100%;
1074
+ height: 100%;
1075
+ }
1076
+ .track {
1077
+ box-sizing: border-box;
1078
+ display: flex;
1079
+ width: 100%;
1080
+ height: 100%;
1081
+ min-height: ${trackHeight}px;
1082
+ align-items: stretch;
1083
+ padding: ${inset}px;
1084
+ overflow: hidden;
1085
+ border-radius: ${radius};
1086
+ background: ${trackColor};
1087
+ }
1088
+ .fill {
1089
+ width: 0%;
1090
+ border-radius: ${fillRadius};
1091
+ background: ${fill};
1092
+ transition: width ${duration}ms cubic-bezier(.42, 0, .58, 1);
1093
+ }
1094
+ .fill.indeterminate {
1095
+ width: ${band * 100}%;
1096
+ transition: none;
1097
+ animation: pnlight-progress-slide ${indeterminateDuration}ms cubic-bezier(.42, 0, .58, 1) infinite;
1098
+ ${holdsBandStill ? "animation: none;" : ""}
1099
+ }
1100
+ @keyframes pnlight-progress-slide {
1101
+ from { transform: translateX(-100%); }
1102
+ to { transform: translateX(${(1 / band) * 100}%); }
1103
+ }
1104
+ @media (prefers-reduced-motion: reduce) {
1105
+ .fill { transition: none; }
1106
+ .fill.indeterminate { animation: none; }
1107
+ }
1108
+ </style>
1109
+ <div
1110
+ class="track"
1111
+ role="progressbar"
1112
+ aria-label="${escapeHtml(label)}"
1113
+ aria-valuemin="0"
1114
+ aria-valuemax="1"
1115
+ >
1116
+ <div class="fill${indeterminate ? " indeterminate" : ""}"></div>
1117
+ </div>
1118
+ `;
1119
+ this.track = this.root.querySelector(".track") ?? undefined;
1120
+ this.fill = this.root.querySelector(".fill") ?? undefined;
1121
+ }
1122
+ applyProgress(progress, animated) {
1123
+ const fill = this.fill;
1124
+ if (!fill)
1125
+ return;
1126
+ if (parseBoolean(this.prop("indeterminate"))) {
1127
+ this.track?.removeAttribute("aria-valuenow");
1128
+ return;
1129
+ }
1130
+ this.track?.setAttribute("aria-valuenow", progress.toFixed(3));
1131
+ if (animated) {
1132
+ fill.style.width = `${progress * 100}%`;
1133
+ return;
1134
+ }
1135
+ // Flush the untransitioned width so the next change animates from here
1136
+ // instead of from the width this element was created with.
1137
+ fill.style.transition = "none";
1138
+ fill.style.width = `${progress * 100}%`;
1139
+ void fill.offsetWidth;
1140
+ fill.style.transition = "";
1141
+ }
1142
+ visualProgress() {
1143
+ const fill = this.fill;
1144
+ const track = this.track;
1145
+ if (!fill || !track || parseBoolean(this.prop("indeterminate"))) {
1146
+ return this.displayedProgress;
1147
+ }
1148
+ const trackStyle = getComputedStyle(track);
1149
+ const available = track.getBoundingClientRect().width
1150
+ - (Number.parseFloat(trackStyle.paddingLeft) || 0)
1151
+ - (Number.parseFloat(trackStyle.paddingRight) || 0);
1152
+ if (available <= 0)
1153
+ return this.displayedProgress;
1154
+ return clampUnit(fill.getBoundingClientRect().width / available);
1155
+ }
1156
+ watchProgressSnapshot(store, instanceId, target) {
1157
+ this.stopProgressSnapshot();
1158
+ if (parseBoolean(this.prop("indeterminate"))
1159
+ || parseBoolean(this.prop("reduced_motion"))
1160
+ || prefersReducedMotion()
1161
+ || Math.abs(this.displayedProgress - target) <= 0.0005) {
1162
+ this.displayedProgress = target;
1163
+ store.set(instanceId, target);
1164
+ return;
1165
+ }
1166
+ const duration = Math.max(0, parseNumber(this.prop("animation_duration"), 0.3)) * 1000;
1167
+ const started = performance.now();
1168
+ const step = (now) => {
1169
+ const visual = this.visualProgress();
1170
+ this.displayedProgress = visual;
1171
+ store.set(instanceId, visual);
1172
+ if (now - started < duration && Math.abs(visual - target) > 0.0005) {
1173
+ this.progressSnapshotFrame = requestAnimationFrame(step);
1174
+ return;
1175
+ }
1176
+ this.displayedProgress = target;
1177
+ store.set(instanceId, target);
1178
+ this.progressSnapshotFrame = undefined;
1179
+ };
1180
+ this.progressSnapshotFrame = requestAnimationFrame(step);
1181
+ }
1182
+ stopProgressSnapshot() {
1183
+ if (this.progressSnapshotFrame == null)
1184
+ return;
1185
+ cancelAnimationFrame(this.progressSnapshotFrame);
1186
+ this.progressSnapshotFrame = undefined;
1187
+ }
1188
+ }
1189
+ class PNLightAnimatedNumber extends PNLightElement {
1190
+ root;
1191
+ text;
1192
+ formatter = new Intl.NumberFormat();
1193
+ instanceId = "";
1194
+ styleSignature = "";
1195
+ initialized = false;
1196
+ displayedValue = 0;
1197
+ targetValue = 0;
1198
+ animationStartValue = 0;
1199
+ animationStart = 0;
1200
+ animationDuration = 0;
1201
+ animationCurve = "ease_out";
1202
+ frame;
1203
+ constructor() {
1204
+ super();
1205
+ this.defineProps(ANIMATED_NUMBER_PROPS);
1206
+ this.root = this.attachShadow({ mode: "open" });
1207
+ }
1208
+ static get observedAttributes() {
1209
+ return [...ANIMATED_NUMBER_PROPS];
1210
+ }
1211
+ connectedCallback() {
1212
+ this.render();
1213
+ this.bindVariableProp("value_variable", "value");
1214
+ }
1215
+ disconnectedCallback() {
1216
+ this.unbindVariableProp();
1217
+ this.stopCounting();
1218
+ }
1219
+ divKitApiCallback(context) {
1220
+ super.divKitApiCallback(context);
1221
+ this.bindVariableProp("value_variable", "value");
1222
+ }
1223
+ attributeChangedCallback(name, _oldValue, value) {
1224
+ this.values.set(name, value);
1225
+ this.scheduleRender();
1226
+ }
1227
+ render() {
1228
+ const instanceId = String(this.prop("instance_id") ?? "pnlight.animated_number.default");
1229
+ const styleSignature = this.currentStyleSignature();
1230
+ const instanceChanged = this.instanceId !== instanceId;
1231
+ const styleChanged = this.styleSignature !== styleSignature;
1232
+ const store = snapshotStore(numberSnapshots, this);
1233
+ const target = parseNumber(this.prop("value"), 0);
1234
+ if (!this.initialized || instanceChanged || styleChanged) {
1235
+ this.installShell();
1236
+ }
1237
+ if (!this.initialized || instanceChanged) {
1238
+ const restored = store.get(instanceId);
1239
+ const initial = this.prop("initial_value");
1240
+ this.displayedValue = restored
1241
+ ?? (initial == null ? target : parseNumber(initial, 0));
1242
+ this.targetValue = this.displayedValue;
1243
+ }
1244
+ const targetChanged = target !== this.targetValue;
1245
+ this.targetValue = target;
1246
+ this.animationCurve = parseCurve(this.prop("curve"));
1247
+ this.animationDuration = parseBoolean(this.prop("reduced_motion")) || prefersReducedMotion()
1248
+ ? 0
1249
+ : Math.max(0, parseNumber(this.prop("animation_duration"), 0.6)) * 1000;
1250
+ this.initialized = true;
1251
+ this.instanceId = instanceId;
1252
+ this.styleSignature = styleSignature;
1253
+ // Persist the on-screen count, not the destination. DivKit replaces this
1254
+ // element on remold; the next instance has to resume from here.
1255
+ store.set(instanceId, this.displayedValue);
1256
+ // An unrelated variable can re-render this element mid-count. Leaving the
1257
+ // running tween alone keeps it from restarting on every render.
1258
+ if (!targetChanged) {
1259
+ if (styleChanged)
1260
+ this.refreshText();
1261
+ return;
1262
+ }
1263
+ this.text?.setAttribute("aria-valuetext", this.formatValue(target));
1264
+ if (this.animationDuration <= 0 || this.displayedValue === target) {
1265
+ this.stopCounting();
1266
+ this.displayedValue = target;
1267
+ store.set(instanceId, this.displayedValue);
1268
+ this.refreshText();
1269
+ return;
1270
+ }
1271
+ // Count from whatever is on screen so a value that changes mid-count stays
1272
+ // continuous.
1273
+ this.animationStartValue = this.displayedValue;
1274
+ this.animationStart = performance.now();
1275
+ this.startCounting();
1276
+ this.refreshText();
1277
+ }
1278
+ currentStyleSignature() {
1279
+ return JSON.stringify(ANIMATED_NUMBER_PROPS
1280
+ .filter((name) => ![
1281
+ "instance_id",
1282
+ "value",
1283
+ "value_variable",
1284
+ "initial_value"
1285
+ ].includes(name))
1286
+ .map((name) => this.prop(name)));
1287
+ }
1288
+ installShell() {
1289
+ const fontSize = Math.max(1, parseNumber(this.prop("font_size"), 34));
1290
+ const weight = fontWeight(this.prop("font_weight"), 700);
1291
+ const color = cssColor(this.prop("text_color"), "#111827");
1292
+ const alignment = String(this.prop("text_alignment") ?? "center").toLowerCase();
1293
+ const justify = alignment === "left" || alignment === "natural"
1294
+ ? "flex-start"
1295
+ : alignment === "right" ? "flex-end" : "center";
1296
+ const monospaced = parseBoolean(this.prop("monospaced_digits"), true);
1297
+ const label = this.prop("accessibility_label");
1298
+ const decimals = Math.min(10, Math.max(0, Math.floor(parseNumber(this.prop("decimals"), 0))));
1299
+ const minimumIntegerDigits = Math.min(20, Math.max(1, Math.floor(parseNumber(this.prop("min_integer_digits"), 1))));
1300
+ this.formatter = new Intl.NumberFormat(undefined, {
1301
+ minimumFractionDigits: decimals,
1302
+ maximumFractionDigits: decimals,
1303
+ minimumIntegerDigits,
1304
+ useGrouping: parseBoolean(this.prop("grouping"), true)
1305
+ });
1306
+ this.root.innerHTML = `
1307
+ <style>
1308
+ :host {
1309
+ display: flex;
1310
+ width: 100%;
1311
+ height: 100%;
1312
+ align-items: center;
1313
+ justify-content: ${justify};
1314
+ }
1315
+ .value {
1316
+ color: ${color};
1317
+ font-size: ${fontSize}px;
1318
+ font-weight: ${weight};
1319
+ line-height: 1.15;
1320
+ white-space: nowrap;
1321
+ /* Tabular figures keep the label from jittering while it counts. */
1322
+ font-variant-numeric: ${monospaced ? "tabular-nums" : "normal"};
1323
+ }
1324
+ </style>
1325
+ <div
1326
+ class="value"
1327
+ role="status"
1328
+ aria-live="off"
1329
+ ${typeof label === "string" && label ? `aria-label="${escapeHtml(label)}"` : ""}
1330
+ ></div>
1331
+ `;
1332
+ this.text = this.root.querySelector(".value") ?? undefined;
1333
+ this.refreshText();
1334
+ }
1335
+ startCounting() {
1336
+ if (this.frame != null)
1337
+ return;
1338
+ const step = (now) => {
1339
+ const elapsed = now - this.animationStart;
1340
+ if (this.animationDuration <= 0 || elapsed >= this.animationDuration) {
1341
+ this.frame = undefined;
1342
+ this.displayedValue = this.targetValue;
1343
+ this.refreshText();
1344
+ snapshotStore(numberSnapshots, this).set(this.instanceId, this.displayedValue);
1345
+ return;
1346
+ }
1347
+ const fraction = easingFraction(this.animationCurve, elapsed / this.animationDuration);
1348
+ this.displayedValue = this.animationStartValue
1349
+ + (this.targetValue - this.animationStartValue) * fraction;
1350
+ this.refreshText();
1351
+ snapshotStore(numberSnapshots, this).set(this.instanceId, this.displayedValue);
1352
+ this.frame = requestAnimationFrame(step);
1353
+ };
1354
+ this.frame = requestAnimationFrame(step);
1355
+ }
1356
+ stopCounting() {
1357
+ if (this.frame == null)
1358
+ return;
1359
+ cancelAnimationFrame(this.frame);
1360
+ this.frame = undefined;
1361
+ }
1362
+ refreshText() {
1363
+ if (!this.text)
1364
+ return;
1365
+ this.text.textContent = this.formatValue(this.displayedValue);
1366
+ }
1367
+ formatValue(value) {
1368
+ const prefix = String(this.prop("prefix") ?? "");
1369
+ const suffix = String(this.prop("suffix") ?? "");
1370
+ return `${prefix}${this.formatter.format(value)}${suffix}`;
1371
+ }
1372
+ }
879
1373
  function escapeHtml(value) {
880
1374
  return value
881
1375
  .replaceAll("&", "&amp;")
@@ -884,26 +1378,37 @@ function escapeHtml(value) {
884
1378
  .replaceAll('"', "&quot;")
885
1379
  .replaceAll("'", "&#039;");
886
1380
  }
887
- export function registerPNLightComponents() {
888
- if (!customElements.get("pnlight-cta-button")) {
889
- customElements.define("pnlight-cta-button", PNLightCtaButton);
890
- }
891
- if (!customElements.get("pnlight-icon-button")) {
892
- customElements.define("pnlight-icon-button", PNLightIconButton);
893
- }
894
- if (!customElements.get("pnlight-circular-loader")) {
895
- customElements.define("pnlight-circular-loader", PNLightCircularLoader);
896
- }
897
- if (!customElements.get("pnlight-animated-prepend-list")) {
898
- customElements.define("pnlight-animated-prepend-list", PNLightAnimatedPrependList);
899
- }
900
- return new Map([
1381
+ /**
1382
+ * Registers the PNLight custom elements and maps them to their `custom_type`.
1383
+ *
1384
+ * Components introduced in a later schema version stay unmapped in an older
1385
+ * document, matching the native renderer's version gate.
1386
+ */
1387
+ export function registerPNLightComponents(schemaVersion = CURRENT_SCHEMA_VERSION) {
1388
+ define("pnlight-cta-button", PNLightCtaButton);
1389
+ define("pnlight-icon-button", PNLightIconButton);
1390
+ define("pnlight-circular-loader", PNLightCircularLoader);
1391
+ define("pnlight-animated-prepend-list", PNLightAnimatedPrependList);
1392
+ define("pnlight-progress-bar", PNLightProgressBar);
1393
+ define("pnlight-animated-number", PNLightAnimatedNumber);
1394
+ const components = new Map([
901
1395
  ["pnlight.cta_button", { element: "pnlight-cta-button" }],
902
1396
  ["pnlight.icon_button", { element: "pnlight-icon-button" }],
903
1397
  ["pnlight.circular_loader", { element: "pnlight-circular-loader" }],
904
1398
  ["pnlight.animated_prepend_list", { element: "pnlight-animated-prepend-list" }],
905
1399
  ["pnlight.animated_threat_list", { element: "pnlight-animated-prepend-list" }]
906
1400
  ]);
1401
+ if (schemaVersion >= NATIVE_COMPONENTS_SCHEMA_VERSION) {
1402
+ components.set("pnlight.progress_bar", { element: "pnlight-progress-bar" });
1403
+ components.set("pnlight.animated_number", { element: "pnlight-animated-number" });
1404
+ }
1405
+ return components;
1406
+ }
1407
+ function define(name, element) {
1408
+ if (customElements.get(name))
1409
+ return;
1410
+ customElements.define(name, element);
907
1411
  }
1412
+ import { CURRENT_SCHEMA_VERSION, NATIVE_COMPONENTS_SCHEMA_VERSION } from "./schema-version.js";
908
1413
  import { ArrowLeft, ArrowRight, Check, ChevronLeft, ChevronRight, CircleHelp, CircleMinus, CirclePlus, CircleX, Ellipsis, Heart, Info, Minus, Plus, Settings, ShieldHalf, Star, Trash2, X, createElement } from "lucide";
909
1414
  //# sourceMappingURL=custom-components.js.map