@pnlight/sdk-react 0.1.0 → 0.3.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,14 +326,44 @@ 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");
357
+ // A custom component with DivKit `actions` is already wrapped by DivKit's
358
+ // action handler. Its shadow-DOM click bubbles to that wrapper, so emitting
359
+ // an additional log-only action here produces "Unknown type of action".
360
+ if (typeof url !== "string" || !url.trim())
361
+ return;
230
362
  const action = {
231
363
  log_id: typeof logId === "string" && logId
232
364
  ? logId
233
365
  : "pnlight_custom_component",
234
- ...(typeof url === "string" && url ? { url } : {})
366
+ url
235
367
  };
236
368
  // Always expose a DOM event for web hosts. DivKit's extension context is
237
369
  // still invoked below, but not every web integration forwards custom URLs
@@ -267,7 +399,7 @@ class PNLightButtonElement extends PNLightElement {
267
399
  this.scheduleRender();
268
400
  }
269
401
  shimmerConfig(defaultEnabled) {
270
- const raw = this.value("shimmer");
402
+ const raw = this.prop("shimmer");
271
403
  const object = parseObject(raw);
272
404
  if (!object) {
273
405
  return {
@@ -290,7 +422,7 @@ class PNLightButtonElement extends PNLightElement {
290
422
  }
291
423
  bounceConfig() {
292
424
  const defaultIdle = this.kind === "cta";
293
- const raw = this.value("bounce");
425
+ const raw = this.prop("bounce");
294
426
  if (!parseObject(raw)) {
295
427
  const enabled = raw == null ? null : parseBoolean(raw);
296
428
  return {
@@ -322,27 +454,27 @@ class PNLightButtonElement extends PNLightElement {
322
454
  }
323
455
  render() {
324
456
  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"));
457
+ const title = String(this.prop("title") ?? "");
458
+ const icon = this.prop("icon");
459
+ const iconSize = parseNumber(this.prop("icon_size"), 20);
460
+ const loading = parseBoolean(this.prop("loading"));
461
+ const disabled = parseBoolean(this.prop("disabled"));
330
462
  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);
463
+ const titleColor = cssColor(this.prop(disabled ? "disabled_title_color" : "title_color"), isIcon ? "#111827" : "#ffffff");
464
+ const iconColor = cssColor(this.prop("icon_color"), titleColor);
333
465
  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");
466
+ const background = cssColor(this.prop(disabled ? "disabled_background_color" : "background_color"), defaultBackground);
467
+ const gradientEnd = this.prop("background_color_end");
336
468
  const shimmer = this.shimmerConfig(!isIcon);
337
469
  const bounce = this.bounceConfig();
338
- const explicitRadius = this.value("corner_radius");
470
+ const explicitRadius = this.prop("corner_radius");
339
471
  const radius = isIcon && explicitRadius == null
340
472
  ? "999px"
341
473
  : `${parseNumber(explicitRadius, 14)}px`;
342
- const glassValue = this.value("glass");
474
+ const glassValue = this.prop("glass");
343
475
  const glassObject = parseObject(glassValue);
344
476
  const glassEnabled = glassValue == null
345
- ? isIcon && this.value("background_color") == null
477
+ ? isIcon && this.prop("background_color") == null
346
478
  : glassObject
347
479
  ? parseBoolean(glassObject.enabled, true)
348
480
  : parseBoolean(glassValue);
@@ -352,8 +484,8 @@ class PNLightButtonElement extends PNLightElement {
352
484
  : glassEnabled
353
485
  ? glassTint
354
486
  : background;
355
- const disabledAlpha = parseNumber(this.value("disabled_alpha"), 0.45);
356
- const accessibilityLabel = String(this.value("accessibility_label") ?? (loading ? "Loading" : title || icon || "Button"));
487
+ const disabledAlpha = parseNumber(this.prop("disabled_alpha"), 0.45);
488
+ const accessibilityLabel = String(this.prop("accessibility_label") ?? (loading ? "Loading" : title || icon || "Button"));
357
489
  this.root.innerHTML = `
358
490
  <style>
359
491
  :host {
@@ -383,8 +515,8 @@ class PNLightButtonElement extends PNLightElement {
383
515
  min-width: 0;
384
516
  align-items: center;
385
517
  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;
518
+ gap: ${parseNumber(this.prop("icon_spacing"), 8)}px;
519
+ padding: ${parseNumber(this.prop("vertical_padding"), isIcon ? 12 : 16)}px ${parseNumber(this.prop("horizontal_padding"), isIcon ? 12 : 24)}px;
388
520
  overflow: hidden;
389
521
  border: ${glassEnabled ? "1px solid rgba(255,255,255,.38)" : "0"};
390
522
  border-radius: ${radius};
@@ -433,8 +565,8 @@ class PNLightButtonElement extends PNLightElement {
433
565
  overflow: hidden;
434
566
  text-overflow: ellipsis;
435
567
  white-space: nowrap;
436
- font-size: ${parseNumber(this.value("font_size"), 18)}px;
437
- font-weight: ${fontWeight(this.value("font_weight"))};
568
+ font-size: ${parseNumber(this.prop("font_size"), 18)}px;
569
+ font-weight: ${fontWeight(this.prop("font_weight"))};
438
570
  line-height: 1.1;
439
571
  }
440
572
  .icon {
@@ -466,10 +598,10 @@ class PNLightButtonElement extends PNLightElement {
466
598
  .spinner {
467
599
  position: relative;
468
600
  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)};
601
+ width: ${this.prop("loading_indicator_style") === "large" ? 25 : 20}px;
602
+ height: ${this.prop("loading_indicator_style") === "large" ? 25 : 20}px;
603
+ border: 2.5px solid color-mix(in srgb, ${cssColor(this.prop("loading_indicator_color"), titleColor)} 28%, transparent);
604
+ border-top-color: ${cssColor(this.prop("loading_indicator_color"), titleColor)};
473
605
  border-radius: 50%;
474
606
  animation: pnlight-spin .72s linear infinite;
475
607
  }
@@ -504,7 +636,7 @@ class PNLightButtonElement extends PNLightElement {
504
636
  ${shimmer.enabled && !inactive ? '<span class="shimmer" aria-hidden="true"></span>' : ""}
505
637
  ${loading
506
638
  ? '<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>` : ""}`}
639
+ : `${icon ? `<span class="icon" aria-hidden="true">${iconMarkup(icon, iconSize, this.prop("icon_weight"))}</span>` : ""}${title ? `<span class="label">${escapeHtml(title)}</span>` : ""}`}
508
640
  </button>
509
641
  </div>
510
642
  `;
@@ -546,10 +678,10 @@ class PNLightCircularLoader extends PNLightElement {
546
678
  this.scheduleRender();
547
679
  }
548
680
  render() {
549
- const large = this.value("style") === "large";
681
+ const large = this.prop("style") === "large";
550
682
  const size = large ? 36 : 22;
551
- const color = cssColor(this.value("color"), "#111827");
552
- const label = String(this.value("accessibility_label") ?? "Loading");
683
+ const color = cssColor(this.prop("color"), "#111827");
684
+ const label = String(this.prop("accessibility_label") ?? "Loading");
553
685
  this.root.innerHTML = `
554
686
  <style>
555
687
  :host {
@@ -585,7 +717,6 @@ class PNLightAnimatedPrependList extends PNLightElement {
585
717
  itemsSignature = "";
586
718
  styleSignature = "";
587
719
  initialized = false;
588
- unsubscribeCount;
589
720
  constructor() {
590
721
  super();
591
722
  this.defineProps(PREPEND_LIST_PROPS);
@@ -596,15 +727,14 @@ class PNLightAnimatedPrependList extends PNLightElement {
596
727
  }
597
728
  connectedCallback() {
598
729
  this.render();
599
- this.bindCountVariable();
730
+ this.bindVariableProp("count_variable", "count");
600
731
  }
601
732
  disconnectedCallback() {
602
- this.unsubscribeCount?.();
603
- this.unsubscribeCount = undefined;
733
+ this.unbindVariableProp();
604
734
  }
605
735
  divKitApiCallback(context) {
606
736
  super.divKitApiCallback(context);
607
- this.bindCountVariable();
737
+ this.bindVariableProp("count_variable", "count");
608
738
  }
609
739
  attributeChangedCallback(name, _oldValue, value) {
610
740
  this.values.set(name, value);
@@ -612,16 +742,16 @@ class PNLightAnimatedPrependList extends PNLightElement {
612
742
  }
613
743
  render() {
614
744
  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");
745
+ const count = Math.min(items.length, Math.max(0, Math.floor(parseNumber(this.prop("count"), 0))));
746
+ const instanceId = String(this.prop("instance_id") ?? "pnlight.animated_prepend_list.default");
617
747
  const itemsSignature = JSON.stringify(items);
618
748
  const styleSignature = this.currentStyleSignature();
619
749
  const instanceChanged = this.instanceId !== instanceId;
620
750
  const styleChanged = this.styleSignature !== styleSignature;
621
751
  const itemsChanged = this.itemsSignature !== itemsSignature;
622
- const snapshotStore = this.snapshotStore();
752
+ const store = snapshotStore(prependListSnapshots, this);
623
753
  const snapshot = instanceChanged
624
- ? snapshotStore.get(instanceId)
754
+ ? store.get(instanceId)
625
755
  : undefined;
626
756
  const canRestoreSnapshot = snapshot?.itemsSignature === itemsSignature &&
627
757
  snapshot.styleSignature === styleSignature;
@@ -633,7 +763,7 @@ class PNLightAnimatedPrependList extends PNLightElement {
633
763
  ? snapshot.displayedCount
634
764
  : 0;
635
765
  }
636
- const reducedMotion = parseBoolean(this.value("reduced_motion")) ||
766
+ const reducedMotion = parseBoolean(this.prop("reduced_motion")) ||
637
767
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;
638
768
  const canAnimateInPlace = this.initialized &&
639
769
  !instanceChanged &&
@@ -664,42 +794,14 @@ class PNLightAnimatedPrependList extends PNLightElement {
664
794
  this.displayedCount = count;
665
795
  this.itemsSignature = itemsSignature;
666
796
  this.styleSignature = styleSignature;
667
- snapshotStore.set(instanceId, {
797
+ store.set(instanceId, {
668
798
  displayedCount: count,
669
799
  itemsSignature,
670
800
  styleSignature
671
801
  });
672
802
  }
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
803
  parsedItems() {
702
- return parseArray(this.value("items")).flatMap((raw) => {
804
+ return parseArray(this.prop("items")).flatMap((raw) => {
703
805
  const item = parseObject(raw);
704
806
  if (!item)
705
807
  return [];
@@ -720,19 +822,19 @@ class PNLightAnimatedPrependList extends PNLightElement {
720
822
  "items",
721
823
  "reduced_motion"
722
824
  ].includes(name))
723
- .map((name) => this.value(name)));
825
+ .map((name) => this.prop(name)));
724
826
  }
725
827
  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));
828
+ const showsIndicator = parseBoolean(this.prop("shows_scroll_indicator"));
829
+ const listHorizontalPadding = Math.max(0, parseNumber(this.prop("list_horizontal_padding"), 4));
830
+ const rowSpacing = Math.max(0, parseNumber(this.prop("row_spacing"), 15));
831
+ const bottomPadding = Math.max(0, parseNumber(this.prop("bottom_padding"), 20));
832
+ const rowHorizontalPadding = Math.max(0, parseNumber(this.prop("row_horizontal_padding"), 16));
833
+ const rowVerticalPadding = Math.max(0, parseNumber(this.prop("row_vertical_padding"), 16));
834
+ const iconSize = Math.max(0, parseNumber(this.prop("icon_size"), 20));
835
+ const iconTextSpacing = Math.max(0, parseNumber(this.prop("icon_text_spacing"), 13));
836
+ const textStatusSpacing = Math.max(0, parseNumber(this.prop("text_status_spacing"), 13));
837
+ const cornerRadius = Math.max(0, parseNumber(this.prop("corner_radius"), 20));
736
838
  this.root.innerHTML = `
737
839
  <style>
738
840
  :host {
@@ -772,7 +874,7 @@ class PNLightAnimatedPrependList extends PNLightElement {
772
874
  padding: ${rowVerticalPadding}px ${rowHorizontalPadding}px;
773
875
  overflow: hidden;
774
876
  border-radius: ${cornerRadius}px;
775
- background: ${cssColor(this.value("card_background_color"), "#fefefe")};
877
+ background: ${cssColor(this.prop("card_background_color"), "#fefefe")};
776
878
  transform-origin: top center;
777
879
  }
778
880
  .icon {
@@ -792,21 +894,21 @@ class PNLightAnimatedPrependList extends PNLightElement {
792
894
  overflow-wrap: anywhere;
793
895
  }
794
896
  .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)};
897
+ color: ${cssColor(this.prop("title_color"), "#1c1c1e")};
898
+ font-size: ${Math.max(1, parseNumber(this.prop("title_font_size"), 14))}px;
899
+ font-weight: ${fontWeight(this.prop("title_font_weight"), 700)};
798
900
  }
799
901
  .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)};
902
+ color: ${cssColor(this.prop("body_color"), "#3a3a3c")};
903
+ font-size: ${Math.max(1, parseNumber(this.prop("body_font_size"), 12))}px;
904
+ font-weight: ${fontWeight(this.prop("body_font_weight"), 300)};
803
905
  }
804
906
  .status {
805
907
  flex: 0 0 auto;
806
908
  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)};
909
+ color: ${cssColor(this.prop("status_color"), "#ff3b30")};
910
+ font-size: ${Math.max(1, parseNumber(this.prop("status_font_size"), 12))}px;
911
+ font-weight: ${fontWeight(this.prop("status_font_weight"), 600)};
810
912
  white-space: nowrap;
811
913
  }
812
914
  </style>
@@ -833,10 +935,10 @@ class PNLightAnimatedPrependList extends PNLightElement {
833
935
  const oldPositions = new Map(oldRows.map((row) => [row.dataset.index ?? "", row.getBoundingClientRect().top]));
834
936
  const row = this.createRow(item, index);
835
937
  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);
938
+ const duration = Math.max(0, parseNumber(this.prop("animation_duration"), 0.36) * 1000);
939
+ const slideDistance = Math.max(0, parseNumber(this.prop("slide_distance"), 8));
940
+ const fades = parseBoolean(this.prop("fade"), true);
941
+ const slides = parseBoolean(this.prop("slide"), true);
840
942
  const easing = "cubic-bezier(.4, 0, .2, 1)";
841
943
  if (duration > 0) {
842
944
  for (const existingRow of oldRows) {
@@ -859,7 +961,7 @@ class PNLightAnimatedPrependList extends PNLightElement {
859
961
  this.viewport?.scrollTo({ top: 0, behavior: "auto" });
860
962
  }
861
963
  createRow(item, index) {
862
- const status = String(this.value("status_text") ?? "");
964
+ const status = String(this.prop("status_text") ?? "");
863
965
  const row = document.createElement("div");
864
966
  row.className = "row";
865
967
  row.dataset.index = String(index);
@@ -876,6 +978,403 @@ class PNLightAnimatedPrependList extends PNLightElement {
876
978
  return row;
877
979
  }
878
980
  }
981
+ class PNLightProgressBar extends PNLightElement {
982
+ root;
983
+ track;
984
+ fill;
985
+ instanceId = "";
986
+ styleSignature = "";
987
+ displayedProgress = 0;
988
+ initialized = false;
989
+ progressSnapshotFrame;
990
+ constructor() {
991
+ super();
992
+ this.defineProps(PROGRESS_BAR_PROPS);
993
+ this.root = this.attachShadow({ mode: "open" });
994
+ }
995
+ static get observedAttributes() {
996
+ return [...PROGRESS_BAR_PROPS];
997
+ }
998
+ connectedCallback() {
999
+ this.render();
1000
+ this.bindVariableProp("progress_variable", "progress");
1001
+ }
1002
+ disconnectedCallback() {
1003
+ this.unbindVariableProp();
1004
+ this.stopProgressSnapshot();
1005
+ }
1006
+ divKitApiCallback(context) {
1007
+ super.divKitApiCallback(context);
1008
+ this.bindVariableProp("progress_variable", "progress");
1009
+ }
1010
+ attributeChangedCallback(name, _oldValue, value) {
1011
+ this.values.set(name, value);
1012
+ this.scheduleRender();
1013
+ }
1014
+ render() {
1015
+ const instanceId = String(this.prop("instance_id") ?? "pnlight.progress_bar.default");
1016
+ const styleSignature = this.currentStyleSignature();
1017
+ const instanceChanged = this.instanceId !== instanceId;
1018
+ const styleChanged = this.styleSignature !== styleSignature;
1019
+ const store = snapshotStore(progressSnapshots, this);
1020
+ const target = clampUnit(parseNumber(this.prop("progress"), 0));
1021
+ if (!this.initialized || instanceChanged || styleChanged) {
1022
+ this.installShell();
1023
+ // A replaced element resumes where the previous one stopped, so DivKit
1024
+ // re-creating the custom element never replays the fill from zero.
1025
+ const restored = store.get(instanceId);
1026
+ const initial = this.prop("initial_progress");
1027
+ this.displayedProgress = restored
1028
+ ?? (initial == null ? target : clampUnit(parseNumber(initial, 0)));
1029
+ this.applyProgress(this.displayedProgress, false);
1030
+ }
1031
+ const reducedMotion = parseBoolean(this.prop("reduced_motion")) || prefersReducedMotion();
1032
+ this.applyProgress(target, !reducedMotion);
1033
+ // Persist the on-screen fill, not the destination. DivKit replaces this
1034
+ // element on remold; the next instance has to resume from here.
1035
+ this.displayedProgress = this.visualProgress();
1036
+ store.set(instanceId, this.displayedProgress);
1037
+ this.watchProgressSnapshot(store, instanceId, target);
1038
+ this.initialized = true;
1039
+ this.instanceId = instanceId;
1040
+ this.styleSignature = styleSignature;
1041
+ }
1042
+ currentStyleSignature() {
1043
+ return JSON.stringify(PROGRESS_BAR_PROPS
1044
+ .filter((name) => ![
1045
+ "instance_id",
1046
+ "progress",
1047
+ "progress_variable",
1048
+ "initial_progress"
1049
+ ].includes(name))
1050
+ .map((name) => this.prop(name)));
1051
+ }
1052
+ installShell() {
1053
+ const trackColor = cssColor(this.prop("track_color"), "rgba(120, 120, 128, 0.2)");
1054
+ const fillColor = cssColor(this.prop("fill_color"), "#007aff");
1055
+ const fillColorEnd = this.prop("fill_color_end");
1056
+ const fill = fillColorEnd == null
1057
+ ? fillColor
1058
+ : `linear-gradient(90deg, ${fillColor}, ${cssColor(fillColorEnd, fillColor)})`;
1059
+ const inset = Math.max(0, parseNumber(this.prop("fill_inset"), 0));
1060
+ const cornerRadius = this.prop("corner_radius");
1061
+ const radius = cornerRadius == null
1062
+ ? "9999px"
1063
+ : `${Math.max(0, parseNumber(cornerRadius, 0))}px`;
1064
+ const fillRadius = cornerRadius == null
1065
+ ? "9999px"
1066
+ : `${Math.max(0, parseNumber(cornerRadius, 0) - inset)}px`;
1067
+ const trackHeight = Math.max(1, parseNumber(this.prop("track_height"), 8));
1068
+ const duration = Math.max(0, parseNumber(this.prop("animation_duration"), 0.3)) * 1000;
1069
+ const indeterminate = parseBoolean(this.prop("indeterminate"));
1070
+ const band = Math.min(1, Math.max(0.05, parseNumber(this.prop("indeterminate_band_width"), 0.3)));
1071
+ const indeterminateDuration = Math.max(0.1, parseNumber(this.prop("indeterminate_duration"), 1.1)) * 1000;
1072
+ const holdsBandStill = parseBoolean(this.prop("reduced_motion"));
1073
+ const label = String(this.prop("accessibility_label") ?? "Progress");
1074
+ this.root.innerHTML = `
1075
+ <style>
1076
+ :host {
1077
+ display: block;
1078
+ width: 100%;
1079
+ height: 100%;
1080
+ }
1081
+ .track {
1082
+ box-sizing: border-box;
1083
+ display: flex;
1084
+ width: 100%;
1085
+ height: 100%;
1086
+ min-height: ${trackHeight}px;
1087
+ align-items: stretch;
1088
+ padding: ${inset}px;
1089
+ overflow: hidden;
1090
+ border-radius: ${radius};
1091
+ background: ${trackColor};
1092
+ }
1093
+ .fill {
1094
+ width: 0%;
1095
+ border-radius: ${fillRadius};
1096
+ background: ${fill};
1097
+ transition: width ${duration}ms cubic-bezier(.42, 0, .58, 1);
1098
+ }
1099
+ .fill.indeterminate {
1100
+ width: ${band * 100}%;
1101
+ transition: none;
1102
+ animation: pnlight-progress-slide ${indeterminateDuration}ms cubic-bezier(.42, 0, .58, 1) infinite;
1103
+ ${holdsBandStill ? "animation: none;" : ""}
1104
+ }
1105
+ @keyframes pnlight-progress-slide {
1106
+ from { transform: translateX(-100%); }
1107
+ to { transform: translateX(${(1 / band) * 100}%); }
1108
+ }
1109
+ @media (prefers-reduced-motion: reduce) {
1110
+ .fill { transition: none; }
1111
+ .fill.indeterminate { animation: none; }
1112
+ }
1113
+ </style>
1114
+ <div
1115
+ class="track"
1116
+ role="progressbar"
1117
+ aria-label="${escapeHtml(label)}"
1118
+ aria-valuemin="0"
1119
+ aria-valuemax="1"
1120
+ >
1121
+ <div class="fill${indeterminate ? " indeterminate" : ""}"></div>
1122
+ </div>
1123
+ `;
1124
+ this.track = this.root.querySelector(".track") ?? undefined;
1125
+ this.fill = this.root.querySelector(".fill") ?? undefined;
1126
+ }
1127
+ applyProgress(progress, animated) {
1128
+ const fill = this.fill;
1129
+ if (!fill)
1130
+ return;
1131
+ if (parseBoolean(this.prop("indeterminate"))) {
1132
+ this.track?.removeAttribute("aria-valuenow");
1133
+ return;
1134
+ }
1135
+ this.track?.setAttribute("aria-valuenow", progress.toFixed(3));
1136
+ if (animated) {
1137
+ fill.style.width = `${progress * 100}%`;
1138
+ return;
1139
+ }
1140
+ // Flush the untransitioned width so the next change animates from here
1141
+ // instead of from the width this element was created with.
1142
+ fill.style.transition = "none";
1143
+ fill.style.width = `${progress * 100}%`;
1144
+ void fill.offsetWidth;
1145
+ fill.style.transition = "";
1146
+ }
1147
+ visualProgress() {
1148
+ const fill = this.fill;
1149
+ const track = this.track;
1150
+ if (!fill || !track || parseBoolean(this.prop("indeterminate"))) {
1151
+ return this.displayedProgress;
1152
+ }
1153
+ const trackStyle = getComputedStyle(track);
1154
+ const available = track.getBoundingClientRect().width
1155
+ - (Number.parseFloat(trackStyle.paddingLeft) || 0)
1156
+ - (Number.parseFloat(trackStyle.paddingRight) || 0);
1157
+ if (available <= 0)
1158
+ return this.displayedProgress;
1159
+ return clampUnit(fill.getBoundingClientRect().width / available);
1160
+ }
1161
+ watchProgressSnapshot(store, instanceId, target) {
1162
+ this.stopProgressSnapshot();
1163
+ if (parseBoolean(this.prop("indeterminate"))
1164
+ || parseBoolean(this.prop("reduced_motion"))
1165
+ || prefersReducedMotion()
1166
+ || Math.abs(this.displayedProgress - target) <= 0.0005) {
1167
+ this.displayedProgress = target;
1168
+ store.set(instanceId, target);
1169
+ return;
1170
+ }
1171
+ const duration = Math.max(0, parseNumber(this.prop("animation_duration"), 0.3)) * 1000;
1172
+ const started = performance.now();
1173
+ const step = (now) => {
1174
+ const visual = this.visualProgress();
1175
+ this.displayedProgress = visual;
1176
+ store.set(instanceId, visual);
1177
+ if (now - started < duration && Math.abs(visual - target) > 0.0005) {
1178
+ this.progressSnapshotFrame = requestAnimationFrame(step);
1179
+ return;
1180
+ }
1181
+ this.displayedProgress = target;
1182
+ store.set(instanceId, target);
1183
+ this.progressSnapshotFrame = undefined;
1184
+ };
1185
+ this.progressSnapshotFrame = requestAnimationFrame(step);
1186
+ }
1187
+ stopProgressSnapshot() {
1188
+ if (this.progressSnapshotFrame == null)
1189
+ return;
1190
+ cancelAnimationFrame(this.progressSnapshotFrame);
1191
+ this.progressSnapshotFrame = undefined;
1192
+ }
1193
+ }
1194
+ class PNLightAnimatedNumber extends PNLightElement {
1195
+ root;
1196
+ text;
1197
+ formatter = new Intl.NumberFormat();
1198
+ instanceId = "";
1199
+ styleSignature = "";
1200
+ initialized = false;
1201
+ displayedValue = 0;
1202
+ targetValue = 0;
1203
+ animationStartValue = 0;
1204
+ animationStart = 0;
1205
+ animationDuration = 0;
1206
+ animationCurve = "ease_out";
1207
+ frame;
1208
+ constructor() {
1209
+ super();
1210
+ this.defineProps(ANIMATED_NUMBER_PROPS);
1211
+ this.root = this.attachShadow({ mode: "open" });
1212
+ }
1213
+ static get observedAttributes() {
1214
+ return [...ANIMATED_NUMBER_PROPS];
1215
+ }
1216
+ connectedCallback() {
1217
+ this.render();
1218
+ this.bindVariableProp("value_variable", "value");
1219
+ }
1220
+ disconnectedCallback() {
1221
+ this.unbindVariableProp();
1222
+ this.stopCounting();
1223
+ }
1224
+ divKitApiCallback(context) {
1225
+ super.divKitApiCallback(context);
1226
+ this.bindVariableProp("value_variable", "value");
1227
+ }
1228
+ attributeChangedCallback(name, _oldValue, value) {
1229
+ this.values.set(name, value);
1230
+ this.scheduleRender();
1231
+ }
1232
+ render() {
1233
+ const instanceId = String(this.prop("instance_id") ?? "pnlight.animated_number.default");
1234
+ const styleSignature = this.currentStyleSignature();
1235
+ const instanceChanged = this.instanceId !== instanceId;
1236
+ const styleChanged = this.styleSignature !== styleSignature;
1237
+ const store = snapshotStore(numberSnapshots, this);
1238
+ const target = parseNumber(this.prop("value"), 0);
1239
+ if (!this.initialized || instanceChanged || styleChanged) {
1240
+ this.installShell();
1241
+ }
1242
+ if (!this.initialized || instanceChanged) {
1243
+ const restored = store.get(instanceId);
1244
+ const initial = this.prop("initial_value");
1245
+ this.displayedValue = restored
1246
+ ?? (initial == null ? target : parseNumber(initial, 0));
1247
+ this.targetValue = this.displayedValue;
1248
+ }
1249
+ const targetChanged = target !== this.targetValue;
1250
+ this.targetValue = target;
1251
+ this.animationCurve = parseCurve(this.prop("curve"));
1252
+ this.animationDuration = parseBoolean(this.prop("reduced_motion")) || prefersReducedMotion()
1253
+ ? 0
1254
+ : Math.max(0, parseNumber(this.prop("animation_duration"), 0.6)) * 1000;
1255
+ this.initialized = true;
1256
+ this.instanceId = instanceId;
1257
+ this.styleSignature = styleSignature;
1258
+ // Persist the on-screen count, not the destination. DivKit replaces this
1259
+ // element on remold; the next instance has to resume from here.
1260
+ store.set(instanceId, this.displayedValue);
1261
+ // An unrelated variable can re-render this element mid-count. Leaving the
1262
+ // running tween alone keeps it from restarting on every render.
1263
+ if (!targetChanged) {
1264
+ if (styleChanged)
1265
+ this.refreshText();
1266
+ return;
1267
+ }
1268
+ this.text?.setAttribute("aria-valuetext", this.formatValue(target));
1269
+ if (this.animationDuration <= 0 || this.displayedValue === target) {
1270
+ this.stopCounting();
1271
+ this.displayedValue = target;
1272
+ store.set(instanceId, this.displayedValue);
1273
+ this.refreshText();
1274
+ return;
1275
+ }
1276
+ // Count from whatever is on screen so a value that changes mid-count stays
1277
+ // continuous.
1278
+ this.animationStartValue = this.displayedValue;
1279
+ this.animationStart = performance.now();
1280
+ this.startCounting();
1281
+ this.refreshText();
1282
+ }
1283
+ currentStyleSignature() {
1284
+ return JSON.stringify(ANIMATED_NUMBER_PROPS
1285
+ .filter((name) => ![
1286
+ "instance_id",
1287
+ "value",
1288
+ "value_variable",
1289
+ "initial_value"
1290
+ ].includes(name))
1291
+ .map((name) => this.prop(name)));
1292
+ }
1293
+ installShell() {
1294
+ const fontSize = Math.max(1, parseNumber(this.prop("font_size"), 34));
1295
+ const weight = fontWeight(this.prop("font_weight"), 700);
1296
+ const color = cssColor(this.prop("text_color"), "#111827");
1297
+ const alignment = String(this.prop("text_alignment") ?? "center").toLowerCase();
1298
+ const justify = alignment === "left" || alignment === "natural"
1299
+ ? "flex-start"
1300
+ : alignment === "right" ? "flex-end" : "center";
1301
+ const monospaced = parseBoolean(this.prop("monospaced_digits"), true);
1302
+ const label = this.prop("accessibility_label");
1303
+ const decimals = Math.min(10, Math.max(0, Math.floor(parseNumber(this.prop("decimals"), 0))));
1304
+ const minimumIntegerDigits = Math.min(20, Math.max(1, Math.floor(parseNumber(this.prop("min_integer_digits"), 1))));
1305
+ this.formatter = new Intl.NumberFormat(undefined, {
1306
+ minimumFractionDigits: decimals,
1307
+ maximumFractionDigits: decimals,
1308
+ minimumIntegerDigits,
1309
+ useGrouping: parseBoolean(this.prop("grouping"), true)
1310
+ });
1311
+ this.root.innerHTML = `
1312
+ <style>
1313
+ :host {
1314
+ display: flex;
1315
+ width: 100%;
1316
+ height: 100%;
1317
+ align-items: center;
1318
+ justify-content: ${justify};
1319
+ }
1320
+ .value {
1321
+ color: ${color};
1322
+ font-size: ${fontSize}px;
1323
+ font-weight: ${weight};
1324
+ line-height: 1.15;
1325
+ white-space: nowrap;
1326
+ /* Tabular figures keep the label from jittering while it counts. */
1327
+ font-variant-numeric: ${monospaced ? "tabular-nums" : "normal"};
1328
+ }
1329
+ </style>
1330
+ <div
1331
+ class="value"
1332
+ role="status"
1333
+ aria-live="off"
1334
+ ${typeof label === "string" && label ? `aria-label="${escapeHtml(label)}"` : ""}
1335
+ ></div>
1336
+ `;
1337
+ this.text = this.root.querySelector(".value") ?? undefined;
1338
+ this.refreshText();
1339
+ }
1340
+ startCounting() {
1341
+ if (this.frame != null)
1342
+ return;
1343
+ const step = (now) => {
1344
+ const elapsed = now - this.animationStart;
1345
+ if (this.animationDuration <= 0 || elapsed >= this.animationDuration) {
1346
+ this.frame = undefined;
1347
+ this.displayedValue = this.targetValue;
1348
+ this.refreshText();
1349
+ snapshotStore(numberSnapshots, this).set(this.instanceId, this.displayedValue);
1350
+ return;
1351
+ }
1352
+ const fraction = easingFraction(this.animationCurve, elapsed / this.animationDuration);
1353
+ this.displayedValue = this.animationStartValue
1354
+ + (this.targetValue - this.animationStartValue) * fraction;
1355
+ this.refreshText();
1356
+ snapshotStore(numberSnapshots, this).set(this.instanceId, this.displayedValue);
1357
+ this.frame = requestAnimationFrame(step);
1358
+ };
1359
+ this.frame = requestAnimationFrame(step);
1360
+ }
1361
+ stopCounting() {
1362
+ if (this.frame == null)
1363
+ return;
1364
+ cancelAnimationFrame(this.frame);
1365
+ this.frame = undefined;
1366
+ }
1367
+ refreshText() {
1368
+ if (!this.text)
1369
+ return;
1370
+ this.text.textContent = this.formatValue(this.displayedValue);
1371
+ }
1372
+ formatValue(value) {
1373
+ const prefix = String(this.prop("prefix") ?? "");
1374
+ const suffix = String(this.prop("suffix") ?? "");
1375
+ return `${prefix}${this.formatter.format(value)}${suffix}`;
1376
+ }
1377
+ }
879
1378
  function escapeHtml(value) {
880
1379
  return value
881
1380
  .replaceAll("&", "&amp;")
@@ -884,26 +1383,37 @@ function escapeHtml(value) {
884
1383
  .replaceAll('"', "&quot;")
885
1384
  .replaceAll("'", "&#039;");
886
1385
  }
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([
1386
+ /**
1387
+ * Registers the PNLight custom elements and maps them to their `custom_type`.
1388
+ *
1389
+ * Components introduced in a later schema version stay unmapped in an older
1390
+ * document, matching the native renderer's version gate.
1391
+ */
1392
+ export function registerPNLightComponents(schemaVersion = CURRENT_SCHEMA_VERSION) {
1393
+ define("pnlight-cta-button", PNLightCtaButton);
1394
+ define("pnlight-icon-button", PNLightIconButton);
1395
+ define("pnlight-circular-loader", PNLightCircularLoader);
1396
+ define("pnlight-animated-prepend-list", PNLightAnimatedPrependList);
1397
+ define("pnlight-progress-bar", PNLightProgressBar);
1398
+ define("pnlight-animated-number", PNLightAnimatedNumber);
1399
+ const components = new Map([
901
1400
  ["pnlight.cta_button", { element: "pnlight-cta-button" }],
902
1401
  ["pnlight.icon_button", { element: "pnlight-icon-button" }],
903
1402
  ["pnlight.circular_loader", { element: "pnlight-circular-loader" }],
904
1403
  ["pnlight.animated_prepend_list", { element: "pnlight-animated-prepend-list" }],
905
1404
  ["pnlight.animated_threat_list", { element: "pnlight-animated-prepend-list" }]
906
1405
  ]);
1406
+ if (schemaVersion >= NATIVE_COMPONENTS_SCHEMA_VERSION) {
1407
+ components.set("pnlight.progress_bar", { element: "pnlight-progress-bar" });
1408
+ components.set("pnlight.animated_number", { element: "pnlight-animated-number" });
1409
+ }
1410
+ return components;
1411
+ }
1412
+ function define(name, element) {
1413
+ if (customElements.get(name))
1414
+ return;
1415
+ customElements.define(name, element);
907
1416
  }
1417
+ import { CURRENT_SCHEMA_VERSION, NATIVE_COMPONENTS_SCHEMA_VERSION } from "./schema-version.js";
908
1418
  import { ArrowLeft, ArrowRight, Check, ChevronLeft, ChevronRight, CircleHelp, CircleMinus, CirclePlus, CircleX, Ellipsis, Heart, Info, Minus, Plus, Settings, ShieldHalf, Star, Trash2, X, createElement } from "lucide";
909
1419
  //# sourceMappingURL=custom-components.js.map