pricing-renderer 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.
Files changed (175) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +142 -25
  3. package/THIRD_PARTY_NOTICES.md +84 -6
  4. package/custom-elements.json +233 -3
  5. package/dist/core/apply-selection.d.ts +8 -0
  6. package/dist/core/apply-selection.d.ts.map +1 -0
  7. package/dist/core/apply-selection.js +76 -0
  8. package/dist/core/apply-selection.js.map +1 -0
  9. package/dist/core/breakdown.d.ts +13 -0
  10. package/dist/core/breakdown.d.ts.map +1 -0
  11. package/dist/core/breakdown.js +56 -0
  12. package/dist/core/breakdown.js.map +1 -0
  13. package/dist/core/compare-sides.d.ts +16 -0
  14. package/dist/core/compare-sides.d.ts.map +1 -0
  15. package/dist/core/compare-sides.js +73 -0
  16. package/dist/core/compare-sides.js.map +1 -0
  17. package/dist/core/compare-values.d.ts +13 -0
  18. package/dist/core/compare-values.d.ts.map +1 -0
  19. package/dist/core/compare-values.js +100 -0
  20. package/dist/core/compare-values.js.map +1 -0
  21. package/dist/core/compare.d.ts +7 -0
  22. package/dist/core/compare.d.ts.map +1 -0
  23. package/dist/core/compare.js +301 -0
  24. package/dist/core/compare.js.map +1 -0
  25. package/dist/core/expression.js +2 -2
  26. package/dist/core/expression.js.map +1 -1
  27. package/dist/core/i18n.d.ts.map +1 -1
  28. package/dist/core/i18n.js +136 -2
  29. package/dist/core/i18n.js.map +1 -1
  30. package/dist/core/index.d.ts +9 -2
  31. package/dist/core/index.d.ts.map +1 -1
  32. package/dist/core/index.js +6 -1
  33. package/dist/core/index.js.map +1 -1
  34. package/dist/core/normalize.d.ts.map +1 -1
  35. package/dist/core/normalize.js +70 -9
  36. package/dist/core/normalize.js.map +1 -1
  37. package/dist/core/plan-relation.d.ts +8 -0
  38. package/dist/core/plan-relation.d.ts.map +1 -0
  39. package/dist/core/plan-relation.js +15 -0
  40. package/dist/core/plan-relation.js.map +1 -0
  41. package/dist/core/price-stabilizer.d.ts +3 -0
  42. package/dist/core/price-stabilizer.d.ts.map +1 -0
  43. package/dist/core/price-stabilizer.js +73 -0
  44. package/dist/core/price-stabilizer.js.map +1 -0
  45. package/dist/core/resolve.d.ts +7 -1
  46. package/dist/core/resolve.d.ts.map +1 -1
  47. package/dist/core/resolve.js +66 -21
  48. package/dist/core/resolve.js.map +1 -1
  49. package/dist/core/search-params.d.ts +15 -0
  50. package/dist/core/search-params.d.ts.map +1 -0
  51. package/dist/core/search-params.js +175 -0
  52. package/dist/core/search-params.js.map +1 -0
  53. package/dist/core/selection-change.d.ts +10 -0
  54. package/dist/core/selection-change.d.ts.map +1 -0
  55. package/dist/core/selection-change.js +124 -0
  56. package/dist/core/selection-change.js.map +1 -0
  57. package/dist/core/types.d.ts +209 -0
  58. package/dist/core/types.d.ts.map +1 -1
  59. package/dist/core/units.d.ts +13 -0
  60. package/dist/core/units.d.ts.map +1 -0
  61. package/dist/core/units.js +84 -0
  62. package/dist/core/units.js.map +1 -0
  63. package/dist/core/utils.d.ts.map +1 -1
  64. package/dist/core/utils.js +91 -32
  65. package/dist/core/utils.js.map +1 -1
  66. package/dist/core/view-model.d.ts +3 -1
  67. package/dist/core/view-model.d.ts.map +1 -1
  68. package/dist/core/view-model.js +113 -7
  69. package/dist/core/view-model.js.map +1 -1
  70. package/dist/element/change-review-define.d.ts +4 -0
  71. package/dist/element/change-review-define.d.ts.map +1 -0
  72. package/dist/element/change-review-define.js +17 -0
  73. package/dist/element/change-review-define.js.map +1 -0
  74. package/dist/element/change-review-element.d.ts +53 -0
  75. package/dist/element/change-review-element.d.ts.map +1 -0
  76. package/dist/element/change-review-element.js +104 -0
  77. package/dist/element/change-review-element.js.map +1 -0
  78. package/dist/element/change-review-events.d.ts +10 -0
  79. package/dist/element/change-review-events.d.ts.map +1 -0
  80. package/dist/element/change-review-events.js +2 -0
  81. package/dist/element/change-review-events.js.map +1 -0
  82. package/dist/element/change-review-renderers.d.ts +23 -0
  83. package/dist/element/change-review-renderers.d.ts.map +1 -0
  84. package/dist/element/change-review-renderers.js +391 -0
  85. package/dist/element/change-review-renderers.js.map +1 -0
  86. package/dist/element/cta.d.ts +45 -0
  87. package/dist/element/cta.d.ts.map +1 -0
  88. package/dist/element/cta.js +90 -0
  89. package/dist/element/cta.js.map +1 -0
  90. package/dist/element/current-selection.d.ts +13 -0
  91. package/dist/element/current-selection.d.ts.map +1 -0
  92. package/dist/element/current-selection.js +57 -0
  93. package/dist/element/current-selection.js.map +1 -0
  94. package/dist/element/define-change-review.d.ts +2 -0
  95. package/dist/element/define-change-review.d.ts.map +1 -0
  96. package/dist/element/define-change-review.js +7 -0
  97. package/dist/element/define-change-review.js.map +1 -0
  98. package/dist/element/events.d.ts +32 -6
  99. package/dist/element/events.d.ts.map +1 -1
  100. package/dist/element/hide-sections.d.ts +6 -0
  101. package/dist/element/hide-sections.d.ts.map +1 -0
  102. package/dist/element/hide-sections.js +26 -0
  103. package/dist/element/hide-sections.js.map +1 -0
  104. package/dist/element/index.d.ts +8 -1
  105. package/dist/element/index.d.ts.map +1 -1
  106. package/dist/element/index.js +3 -0
  107. package/dist/element/index.js.map +1 -1
  108. package/dist/element/pricing-renderer-element.d.ts +95 -7
  109. package/dist/element/pricing-renderer-element.d.ts.map +1 -1
  110. package/dist/element/pricing-renderer-element.js +563 -217
  111. package/dist/element/pricing-renderer-element.js.map +1 -1
  112. package/dist/element/renderers.d.ts +49 -4
  113. package/dist/element/renderers.d.ts.map +1 -1
  114. package/dist/element/renderers.js +795 -263
  115. package/dist/element/renderers.js.map +1 -1
  116. package/dist/element/same-input.d.ts +3 -0
  117. package/dist/element/same-input.d.ts.map +1 -0
  118. package/dist/element/same-input.js +56 -0
  119. package/dist/element/same-input.js.map +1 -0
  120. package/dist/react/headless.d.ts +9 -0
  121. package/dist/react/headless.d.ts.map +1 -0
  122. package/dist/react/headless.js +8 -0
  123. package/dist/react/headless.js.map +1 -0
  124. package/dist/react/index.d.ts +54 -4
  125. package/dist/react/index.d.ts.map +1 -1
  126. package/dist/react/index.js +156 -6
  127. package/dist/react/index.js.map +1 -1
  128. package/dist/react/provider.d.ts +17 -0
  129. package/dist/react/provider.d.ts.map +1 -0
  130. package/dist/react/provider.js +38 -0
  131. package/dist/react/provider.js.map +1 -0
  132. package/dist/react/selection-store.d.ts +51 -0
  133. package/dist/react/selection-store.d.ts.map +1 -0
  134. package/dist/react/selection-store.js +239 -0
  135. package/dist/react/selection-store.js.map +1 -0
  136. package/dist/react/slots-types.d.ts +38 -0
  137. package/dist/react/slots-types.d.ts.map +1 -0
  138. package/dist/react/slots-types.js +2 -0
  139. package/dist/react/slots-types.js.map +1 -0
  140. package/dist/react/slots.d.ts +40 -0
  141. package/dist/react/slots.d.ts.map +1 -0
  142. package/dist/react/slots.js +165 -0
  143. package/dist/react/slots.js.map +1 -0
  144. package/dist/react/use-pricing-selection.d.ts +10 -0
  145. package/dist/react/use-pricing-selection.d.ts.map +1 -0
  146. package/dist/react/use-pricing-selection.js +44 -0
  147. package/dist/react/use-pricing-selection.js.map +1 -0
  148. package/dist/react/use-pricing-source.d.ts +34 -0
  149. package/dist/react/use-pricing-source.d.ts.map +1 -0
  150. package/dist/react/use-pricing-source.js +186 -0
  151. package/dist/react/use-pricing-source.js.map +1 -0
  152. package/dist/react/use-renderer-state.d.ts +22 -0
  153. package/dist/react/use-renderer-state.d.ts.map +1 -0
  154. package/dist/react/use-renderer-state.js +112 -0
  155. package/dist/react/use-renderer-state.js.map +1 -0
  156. package/dist/space/index.d.ts +26 -0
  157. package/dist/space/index.d.ts.map +1 -0
  158. package/dist/space/index.js +71 -0
  159. package/dist/space/index.js.map +1 -0
  160. package/dist/styles/base.css +1030 -76
  161. package/dist/styles/theme-minimal.css +103 -0
  162. package/dist/styles/theme.css +22 -3
  163. package/dist/styles/tokens-shadcn.css +40 -0
  164. package/dist/yaml/index.d.ts +2 -0
  165. package/dist/yaml/index.d.ts.map +1 -1
  166. package/dist/yaml/index.js +1 -0
  167. package/dist/yaml/index.js.map +1 -1
  168. package/dist/yaml/load.d.ts.map +1 -1
  169. package/dist/yaml/load.js +106 -17
  170. package/dist/yaml/load.js.map +1 -1
  171. package/dist/yaml/source.d.ts +18 -0
  172. package/dist/yaml/source.d.ts.map +1 -0
  173. package/dist/yaml/source.js +46 -0
  174. package/dist/yaml/source.js.map +1 -0
  175. package/package.json +25 -13
@@ -1,27 +1,127 @@
1
+ var _a;
1
2
  import { LitElement, html, nothing } from 'lit';
2
- import { cloneValue, createPricingViewModel, getPricingRendererConfig, mergePresentationOptions, mergeSelection, normalizeAddOnQuantity, normalizePricing, setAtPath, translate, } from '../core/index.js';
3
- import { loadPricingFromUrl, parsePricingYaml, } from '../yaml/index.js';
4
- import { renderAddOns, renderBilling, renderCatalogMetadata, renderComparison, renderHeader, renderPendingDialog, renderPlans, renderSummary, renderVariables, } from './renderers.js';
3
+ import { applySelectionChange, cloneValue, compareSelections, createPriceStabilizer, createPricingViewModel, deepEqual, getMessages, getPricingRendererConfig, mergePresentationOptions, mergeSelection, planSelectionChange, reconcileSelection, selectionInEffect, selectionWithAddOnQuantity, selectionWithVariable, translate, } from '../core/index.js';
4
+ import { applySelectionInput } from '../core/apply-selection.js';
5
+ import { planCtaView, summaryActionState } from './cta.js';
6
+ import { currentSelectionDiagnostics, deriveCurrentContext } from './current-selection.js';
7
+ import { inputChanged } from './same-input.js';
8
+ import { loadPricingSource, } from '../yaml/index.js';
9
+ import { pricingSourceError } from '../yaml/source.js';
10
+ import { comparisonRowTotal, DEFAULT_ADDON_FILTER_THRESHOLD, DEFAULT_TOOLBAR_THRESHOLD, renderAddOns, renderBilling, renderCatalogMetadata, renderComparison, renderErrorSlotShell, renderHeader, renderLoadingSlotShell, renderPendingDialog, renderPlans, renderSummary, renderVariables, resolveOnlyDifferences, toolbarThresholdOf, addOnFilterThresholdOf, instanceId, heroTitle, PRICING_SECTION_NAMES, PRICING_SLOT_NAMES, } from './renderers.js';
11
+ import { hiddenNamesOf, sectionsConverter } from './hide-sections.js';
12
+ // Everything after the plans lives in one flex column, so a sticky summary has the whole column as
13
+ // its containing block (a grid item would only have its own row).
14
+ //
15
+ // The summary never sits inside the add-on layout, which is where a pricing with add-ons used to
16
+ // put it. A sticky element is only pinned while its own containing block is on screen, and that
17
+ // layout is one section near the end of the page: the bottom bar it is meant to be existed only
18
+ // after the reader had already scrolled past the comparison and the add-on grid -- furthest away
19
+ // exactly when add-ons are what move the total.
20
+ //
21
+ // At 840px or more, outside the compact layout and with a plan selected, it is the first child of
22
+ // the column and sticks to its top. Otherwise it closes the content, outside the column, where its
23
+ // containing block is everything after the plans and the bottom bar is pinned for that whole scroll.
24
+ export function renderAfterPlans(state, wide = true) {
25
+ const { selection, compact } = state;
26
+ const hidden = state.hiddenSections;
27
+ const summaryHidden = Boolean(hidden?.has('summary'));
28
+ const docked = !compact && wide && !summaryHidden && selection.planId !== undefined;
29
+ const summary = summaryHidden ? nothing : renderSummary(state);
30
+ // The add-on layout never holds the summary now, whatever the width.
31
+ const inner = { ...state, summaryOutsideAddOns: true };
32
+ const parts = [
33
+ docked ? summary : nothing,
34
+ hidden?.has('comparison') ? nothing : renderComparison(inner),
35
+ renderAddOns(inner),
36
+ ];
37
+ return html `<div class="pr-flow">${parts}</div>
38
+ ${docked ? nothing : summary}`;
39
+ }
40
+ const breakdownToggleSelector = '[data-pr-part="breakdown-toggle"]';
41
+ const comparisonRowSelector = '[data-pr-part="comparison-row"]';
42
+ const addOnCardSelector = '[data-pr-part="add-on-card"]';
43
+ const breakdownPanelSelector = '[data-pr-part="breakdown"]';
44
+ let breakdownPanelCount = 0;
45
+ // the sentence is chosen by the first error diagnostic.
46
+ // The code prefix is cut off so that no partial code is spelled out: the docs test treats every
47
+ // full code token of the source as a code to document.
48
+ function errorMessageKey(diagnostics) {
49
+ const first = diagnostics.find((item) => item.severity === 'error');
50
+ const name = (first?.code ?? '').replace(/^PR_/, '');
51
+ if (name.startsWith('SOURCE_') || name === 'FETCH_UNAVAILABLE')
52
+ return 'pricing.errorSource';
53
+ if (name.startsWith('YAML_') || name === 'INPUT_INVALID')
54
+ return 'pricing.errorFormat';
55
+ if (name.startsWith('VERSION_'))
56
+ return 'pricing.errorVersion';
57
+ return 'pricing.errorGeneric';
58
+ }
59
+ // absent is automatic, `false` is off, anything else is on
60
+ export const onlyDifferencesConverter = {
61
+ fromAttribute(value) {
62
+ if (value === null)
63
+ return undefined;
64
+ return value !== 'false';
65
+ },
66
+ };
67
+ // Whitespace separated; only tokens that exactly match a section name are kept, in order.
68
+ export const hideSectionsConverter = sectionsConverter(PRICING_SECTION_NAMES);
69
+ // A value that is not an array hides nothing, and neither does an item that is not a section name.
70
+ function hiddenSectionsOf(value) {
71
+ return hiddenNamesOf(PRICING_SECTION_NAMES, value);
72
+ }
73
+ // A value that is not an array requests nothing, and neither does an item that is not a slot name.
74
+ function requestedSlotsOf(value) {
75
+ const names = new Set();
76
+ if (!Array.isArray(value))
77
+ return names;
78
+ for (const item of value) {
79
+ if (PRICING_SLOT_NAMES.includes(item)) {
80
+ names.add(item);
81
+ }
82
+ }
83
+ return names;
84
+ }
85
+ const slotNodeSelector = '[data-pr-slot]';
86
+ const slotOwnerSelector = '[data-pr-root], [data-pr-slot]';
5
87
  export class PricingRendererElement extends LitElement {
6
88
  constructor() {
7
89
  super();
90
+ this._lastSlotsDispatch = { nodes: [], data: [] };
8
91
  this._status = 'idle';
9
92
  this._diagnostics = [];
10
93
  this._compact = true;
94
+ this._wide = false;
11
95
  this._search = '';
12
- this._onlyDifferences = false;
13
96
  this._expandedGroups = new Set();
14
97
  this._revealedGroups = new Set();
15
- this._lastValidPlanPrices = {};
16
- this._lastValidAddOnPrices = {};
98
+ this._addOnSearch = '';
99
+ this._addOnsRevealed = false;
100
+ // every view model the element builds goes
101
+ // through the stabilizer, which keeps the last valid prices and folds the subtotal from the
102
+ // breakdown, so a removed add-on has no line and no share of the subtotal
103
+ this._priceStabilizer = createPriceStabilizer();
17
104
  this._loadSequence = 0;
18
105
  this._loadedOnce = false;
19
106
  this._reloadOnPropertyChange = false;
107
+ this._breakdownOpen = false;
108
+ // the element renders in light DOM, so ids are global
109
+ this._instanceNumber = ++breakdownPanelCount;
110
+ this._breakdownId = `pr-breakdown-${this._instanceNumber}`;
111
+ // the prefix of every other id of this instance
112
+ this._idPrefix = `pr-${this._instanceNumber}`;
20
113
  this._selectPlan = (planId) => {
114
+ if (!this._normalized)
115
+ return;
21
116
  const selection = this._effectiveSelection();
22
117
  if (!selection)
23
118
  return;
24
- this._setSelection({ ...selection, planId });
119
+ const change = planSelectionChange(this._normalized, selection, { type: 'plan', planId });
120
+ if (change.kind === 'apply') {
121
+ this._setSelection(change.selection);
122
+ return;
123
+ }
124
+ this._openChangeDialog(change.pending);
25
125
  };
26
126
  this._selectBilling = (billingPeriod) => {
27
127
  const selection = this._effectiveSelection();
@@ -33,9 +133,7 @@ export class PricingRendererElement extends LitElement {
33
133
  const selection = this._effectiveSelection();
34
134
  if (!selection)
35
135
  return;
36
- const variables = cloneValue(selection.variables);
37
- setAtPath(variables, path, value);
38
- this._setSelection({ ...selection, variables });
136
+ this._setSelection(selectionWithVariable(selection, path, value));
39
137
  };
40
138
  this._resetVariables = () => {
41
139
  const selection = this._effectiveSelection();
@@ -43,48 +141,29 @@ export class PricingRendererElement extends LitElement {
43
141
  return;
44
142
  this._setSelection({ ...selection, variables: cloneValue(this._normalized.variables) });
45
143
  };
144
+ // the dependencies to add are the transitive closure of
145
+ // dependsOn, so a two-level chain is added whole
46
146
  this._toggleAddOn = (addOnId, selected) => {
47
147
  if (!this._normalized)
48
148
  return;
49
149
  const selection = this._effectiveSelection();
50
150
  if (!selection)
51
151
  return;
52
- const addOn = this._normalized.addOns.find((candidate) => candidate.id === addOnId);
53
- if (!addOn)
152
+ // an unknown add-on changes nothing and dispatches no event
153
+ if (!this._normalized.addOns.some((candidate) => candidate.id === addOnId))
154
+ return;
155
+ const change = planSelectionChange(this._normalized, selection, {
156
+ type: 'addOn',
157
+ addOnId,
158
+ selected,
159
+ });
160
+ if (change.kind === 'confirm') {
161
+ this._openChangeDialog(change.pending);
54
162
  return;
55
- let addIds = [];
56
- let removeIds = [];
57
- if (selected) {
58
- addIds = [...this._collectDependencies(addOnId)].filter((id) => !selection.addOns[id]?.selected);
59
- const conflicts = new Set(addOn.excludes);
60
- for (const candidate of this._normalized.addOns) {
61
- if (candidate.excludes.includes(addOnId))
62
- conflicts.add(candidate.id);
63
- }
64
- for (const dependency of addIds) {
65
- const item = this._normalized.addOns.find((candidate) => candidate.id === dependency);
66
- item?.excludes.forEach((id) => conflicts.add(id));
67
- }
68
- removeIds = [...conflicts].filter((id) => selection.addOns[id]?.selected);
69
- }
70
- else {
71
- removeIds = this._selectedDependents(addOnId);
72
- }
73
- const pending = { addOnId, select: selected, addIds, removeIds };
74
- if (addIds.length || removeIds.length) {
75
- this._pendingAddOnChange = pending;
76
- this._previousFocus =
77
- this.ownerDocument.activeElement instanceof HTMLElement
78
- ? this.ownerDocument.activeElement
79
- : undefined;
80
- this.requestUpdate();
81
- void this.updateComplete.then(() => {
82
- this.querySelector('.pr-dialog button[autofocus]')?.focus();
83
- });
84
- }
85
- else {
86
- this._applyAddOnChange(pending);
87
163
  }
164
+ this._pendingAddOnChange = undefined;
165
+ this._setSelection(change.selection);
166
+ void this.updateComplete.then(() => this._restoreDialogFocus());
88
167
  };
89
168
  this._updateAddOnQuantity = (addOnId, quantity) => {
90
169
  if (!this._normalized)
@@ -92,19 +171,7 @@ export class PricingRendererElement extends LitElement {
92
171
  const selection = this._effectiveSelection();
93
172
  if (!selection)
94
173
  return;
95
- const current = selection.addOns[addOnId] ?? { selected: true, quantity };
96
- const normalized = normalizeAddOnQuantity(this._normalized, addOnId, {
97
- ...current,
98
- selected: true,
99
- quantity,
100
- });
101
- this._setSelection({
102
- ...selection,
103
- addOns: {
104
- ...selection.addOns,
105
- [addOnId]: { selected: true, quantity: normalized },
106
- },
107
- });
174
+ this._setSelection(selectionWithAddOnQuantity(this._normalized, selection, addOnId, quantity));
108
175
  };
109
176
  this._confirmAddOnChange = () => {
110
177
  if (this._pendingAddOnChange) {
@@ -116,17 +183,23 @@ export class PricingRendererElement extends LitElement {
116
183
  this.requestUpdate();
117
184
  void this.updateComplete.then(() => this._restoreDialogFocus());
118
185
  };
119
- this._triggerAction = (actionId, planId, href, metadata) => {
120
- const viewModel = this._viewModel();
121
- if (!viewModel)
186
+ this._triggerAction = (actionId, planId, href, metadata, origin) => {
187
+ // a link CTA sees `false` and cancels its navigation
188
+ if (this.pendingActionId)
122
189
  return false;
190
+ const current = this._viewModel();
191
+ if (!current || !this._normalized)
192
+ return false;
193
+ const reconciled = reconcileSelection(this._normalized, current.resolved.selection).selection;
194
+ const viewModel = this._priceStabilizer.stabilize(createPricingViewModel(this._normalized, reconciled, { ...this._viewModelOptions() }));
123
195
  const detail = {
124
196
  actionId,
125
197
  ...(planId ? { planId } : {}),
126
198
  ...(href ? { href } : {}),
127
- selection: viewModel.resolved.selection,
199
+ selection: reconciled,
128
200
  resolved: viewModel.resolved,
129
201
  ...(metadata ? { metadata } : {}),
202
+ ...(origin ? { relation: origin.relation, source: origin.source } : {}),
130
203
  };
131
204
  const event = new CustomEvent('pricing-action', {
132
205
  detail,
@@ -152,13 +225,30 @@ export class PricingRendererElement extends LitElement {
152
225
  : new Set();
153
226
  this.requestUpdate();
154
227
  };
155
- this._revealGroup = (groupId) => {
228
+ // focus moves to the first row the reveal adds
229
+ this._revealGroup = (groupId, origin, byKeyboard = false) => {
230
+ const container = origin?.closest('tbody, .pr-comparison-group') ?? undefined;
231
+ const before = container?.querySelectorAll(comparisonRowSelector).length ?? 0;
156
232
  this._revealedGroups = new Set([...this._revealedGroups, groupId]);
157
233
  this.requestUpdate();
234
+ if (!container)
235
+ return;
236
+ void this.updateComplete.then(() => {
237
+ const rows = container.querySelectorAll(comparisonRowSelector);
238
+ if (rows.length > before)
239
+ this._focusRevealed(rows[before], byKeyboard);
240
+ });
241
+ };
242
+ // no selection change resets the open state
243
+ this._toggleBreakdown = () => {
244
+ this._breakdownOpen = !this._breakdownOpen;
245
+ this.requestUpdate();
158
246
  };
159
247
  this._handleDialogKeyboard = (event) => {
160
- if (!this._pendingAddOnChange)
248
+ if (!this._pendingAddOnChange) {
249
+ this._closeBreakdownOnEscape(event);
161
250
  return;
251
+ }
162
252
  if (event.key === 'Escape') {
163
253
  event.preventDefault();
164
254
  this._cancelAddOnChange();
@@ -195,14 +285,31 @@ export class PricingRendererElement extends LitElement {
195
285
  this.requestUpdate();
196
286
  },
197
287
  updateOnlyDifferences: (value) => {
198
- this._onlyDifferences = value;
288
+ this._onlyDifferencesOverride = value;
199
289
  this.requestUpdate();
200
290
  },
201
291
  toggleGroup: this._toggleGroup,
202
292
  setAllGroups: this._setAllGroups,
203
293
  revealGroup: this._revealGroup,
294
+ updateAddOnSearch: (value) => {
295
+ this._addOnSearch = value;
296
+ this.requestUpdate();
297
+ },
298
+ // focus moves to the first add-on card the reveal adds
299
+ revealAddOns: (byKeyboard = false) => {
300
+ const shown = () => [...this.querySelectorAll(addOnCardSelector)];
301
+ const before = new Set(shown().map((card) => card.dataset.addOnId));
302
+ this._addOnsRevealed = true;
303
+ this.requestUpdate();
304
+ void this.updateComplete.then(() => {
305
+ const added = shown().find((card) => !before.has(card.dataset.addOnId));
306
+ if (added)
307
+ this._focusRevealed(added, byKeyboard);
308
+ });
309
+ },
204
310
  confirmAddOnChange: this._confirmAddOnChange,
205
311
  cancelAddOnChange: this._cancelAddOnChange,
312
+ toggleBreakdown: this._toggleBreakdown,
206
313
  };
207
314
  const configuration = getPricingRendererConfig();
208
315
  this.locale = configuration.locale;
@@ -215,6 +322,41 @@ export class PricingRendererElement extends LitElement {
215
322
  this.layout = 'auto';
216
323
  this.visibility = 'public-only';
217
324
  this.theme = 'light';
325
+ this.diagnostics = 'auto';
326
+ this.toolbarThreshold = DEFAULT_TOOLBAR_THRESHOLD;
327
+ this.addOnFilterThreshold = DEFAULT_ADDON_FILTER_THRESHOLD;
328
+ }
329
+ get density() {
330
+ return this._density === 'compact' ? 'compact' : 'comfortable';
331
+ }
332
+ set density(value) {
333
+ // The old value is the one read back: Lit takes the first old value as the default that a
334
+ // removed attribute restores, and it must be comfortable.
335
+ const previous = this.density;
336
+ const wasSet = this._density !== undefined && this._density !== null;
337
+ this._density = value;
338
+ // Unsetting, for example a React prop that becomes undefined, removes the attribute instead of
339
+ // reflecting density="comfortable". A value set and unset before the same update is still
340
+ // reflected, as comfortable.
341
+ if (value === undefined || value === null) {
342
+ if (wasSet)
343
+ this.removeAttribute('density');
344
+ return;
345
+ }
346
+ this.requestUpdate('density', previous);
347
+ }
348
+ // The element keeps a deep copy, so a later change of the host's object is not seen until the
349
+ // host sets the property again, and the getter returns a copy, so a change of what it returns is
350
+ // not seen either. An equal value keeps the stored copy: a host that assigns its props on every
351
+ // render does not report the diagnostics again. It never becomes the selection in effect, so
352
+ // setting it emits no `pricing-selection-change`.
353
+ get currentSelection() {
354
+ return cloneValue(this._currentSelection);
355
+ }
356
+ set currentSelection(value) {
357
+ if (deepEqual(value, this._currentSelection))
358
+ return;
359
+ this._currentSelection = cloneValue(value);
218
360
  }
219
361
  createRenderRoot() {
220
362
  return this;
@@ -223,6 +365,8 @@ export class PricingRendererElement extends LitElement {
223
365
  super.connectedCallback();
224
366
  this.setAttribute('data-pr-root', '');
225
367
  this.addEventListener('keydown', this._handleDialogKeyboard);
368
+ if (this.hasUpdated)
369
+ this._resumeAfterReconnect();
226
370
  }
227
371
  disconnectedCallback() {
228
372
  this._resizeObserver?.disconnect();
@@ -230,7 +374,18 @@ export class PricingRendererElement extends LitElement {
230
374
  this.removeEventListener('keydown', this._handleDialogKeyboard);
231
375
  super.disconnectedCallback();
232
376
  }
233
- firstUpdated() {
377
+ // The disconnect stopped the observer and aborted the load in flight; an aborted load is dropped
378
+ // without a result, so a status still `loading` would never end. A ready or failed element keeps
379
+ // what it shows.
380
+ _resumeAfterReconnect() {
381
+ this._observeSize();
382
+ if (this._status === 'loading')
383
+ void this.reload();
384
+ }
385
+ // Observes the size of the element with a new observer and measures its width once.
386
+ _observeSize() {
387
+ this._resizeObserver?.disconnect();
388
+ this._resizeObserver = undefined;
234
389
  if (typeof ResizeObserver === 'function') {
235
390
  this._resizeObserver = new ResizeObserver((entries) => {
236
391
  const width = entries[0]?.contentRect.width ?? this.getBoundingClientRect().width;
@@ -239,11 +394,18 @@ export class PricingRendererElement extends LitElement {
239
394
  this._resizeObserver.observe(this);
240
395
  }
241
396
  this._updateCompactLayout(this.getBoundingClientRect().width);
397
+ }
398
+ firstUpdated() {
399
+ this._observeSize();
242
400
  void this.reload();
243
401
  queueMicrotask(() => {
244
402
  this._reloadOnPropertyChange = true;
245
403
  });
246
404
  }
405
+ willUpdate(changed) {
406
+ if (changed.has('onlyDifferences'))
407
+ this._onlyDifferencesOverride = undefined;
408
+ }
247
409
  updated(changed) {
248
410
  const inputChanged = changed.has('pricing') ||
249
411
  changed.has('yaml') ||
@@ -257,28 +419,80 @@ export class PricingRendererElement extends LitElement {
257
419
  if (changed.has('layout')) {
258
420
  this._updateCompactLayout(this.getBoundingClientRect().width);
259
421
  }
422
+ this._reportCurrentSelection();
423
+ this._reportSlots();
424
+ }
425
+ // Once per change of the slot nodes this element rendered, of their data-* values or, while a
426
+ // summary slot node exists, of the selection and the resolved prices. A node that the host or a
427
+ // nested renderer put inside a slot node has another owner and is not listed.
428
+ _reportSlots() {
429
+ const nodes = [...this.querySelectorAll(slotNodeSelector)].filter((node) => node.isConnected && node.parentElement?.closest(slotOwnerSelector) === this);
430
+ const data = nodes.map((node) => JSON.stringify(Object.entries(node.dataset)));
431
+ const summary = nodes.some((node) => node.dataset.prSlot === 'summary-actions')
432
+ ? this._snapshot()
433
+ : undefined;
434
+ const last = this._lastSlotsDispatch;
435
+ if (nodes.length === last.nodes.length &&
436
+ nodes.every((node, index) => node === last.nodes[index] && data[index] === last.data[index]) &&
437
+ deepEqual(summary, last.summary)) {
438
+ return;
439
+ }
440
+ this._lastSlotsDispatch = { nodes, data, ...(summary ? { summary } : {}) };
441
+ this.dispatchEvent(new CustomEvent('pricing-slots-change', {
442
+ detail: { nodes: [...nodes] },
443
+ bubbles: true,
444
+ composed: true,
445
+ }));
446
+ }
447
+ // The same pricing-action as a click on the plan's built-in CTA, for a host that draws its own.
448
+ triggerPlanAction(planId) {
449
+ if (!this.ctaEnabled || this.pendingActionId || !this._isReady())
450
+ return false;
451
+ if (hiddenSectionsOf(this.hideSections).has('plans'))
452
+ return false;
453
+ const viewModel = this._viewModel();
454
+ const plan = viewModel?.plans.find((candidate) => candidate.id === planId);
455
+ if (!viewModel || !plan)
456
+ return false;
457
+ const current = deriveCurrentContext(this._normalized, this._currentSelection);
458
+ const cta = planCtaView(viewModel, plan, {
459
+ currentPlanId: current?.planId,
460
+ pendingActionId: this.pendingActionId,
461
+ });
462
+ if (cta.disabled)
463
+ return false;
464
+ const origin = cta.relation
465
+ ? { relation: cta.relation, source: 'plan' }
466
+ : undefined;
467
+ return this._triggerAction(cta.actionId, plan.id, cta.eventHref, cta.metadata, origin);
468
+ }
469
+ // Once per change of the current selection or of the loaded pricing; not in the rendered list.
470
+ _reportCurrentSelection() {
471
+ if (!this._isReady())
472
+ return;
473
+ if (this._diagnosedSelection === this._currentSelection &&
474
+ this._diagnosedPricing === this._normalized) {
475
+ return;
476
+ }
477
+ this._diagnosedSelection = this._currentSelection;
478
+ this._diagnosedPricing = this._normalized;
479
+ this._dispatchSelectionDiagnostics(currentSelectionDiagnostics(this._normalized, this._currentSelection));
260
480
  }
261
481
  async reload() {
262
482
  this._loadedOnce = true;
263
- const sources = [
264
- this.pricing !== undefined ? 'pricing' : undefined,
265
- this.yaml !== undefined ? 'yaml' : undefined,
266
- this.src ? 'src' : undefined,
267
- ].filter(Boolean);
268
- if (sources.length !== 1) {
269
- this._applyLoadResult({
270
- ok: false,
271
- diagnostics: [
272
- {
273
- code: sources.length === 0 ? 'PR_SOURCE_MISSING' : 'PR_SOURCE_CONFLICT',
274
- severity: 'error',
275
- message: sources.length === 0
276
- ? 'Provide exactly one pricing source: pricing, yaml or src.'
277
- : 'Only one pricing source can be provided at a time.',
278
- path: '$',
279
- },
280
- ],
281
- });
483
+ const input = {
484
+ ...(this.pricing !== undefined ? { pricing: this.pricing } : {}),
485
+ ...(this.yaml !== undefined ? { yaml: this.yaml } : {}),
486
+ ...(this.src !== undefined ? { src: this.src } : {}),
487
+ };
488
+ const sourceError = pricingSourceError(input);
489
+ if (sourceError) {
490
+ // the load in flight must neither finish nor be
491
+ // applied after this error
492
+ this._abortController?.abort();
493
+ this._abortController = undefined;
494
+ this._loadSequence += 1;
495
+ this._applyLoadResult(sourceError);
282
496
  return;
283
497
  }
284
498
  this._abortController?.abort();
@@ -287,43 +501,116 @@ export class PricingRendererElement extends LitElement {
287
501
  const sequence = ++this._loadSequence;
288
502
  this._status = 'loading';
289
503
  this.requestUpdate();
290
- let result;
291
- if (this.pricing !== undefined) {
292
- result = normalizePricing(this.pricing, this.normalizeOptions);
504
+ let result = loadPricingSource(input, {
505
+ ...(this.request ? { request: this.request } : {}),
506
+ ...(this.loadPricing ? { loadPricing: this.loadPricing } : {}),
507
+ ...(this.normalizeOptions ? { normalizeOptions: this.normalizeOptions } : {}),
508
+ signal: controller.signal,
509
+ });
510
+ if (result instanceof Promise) {
511
+ result = await result;
293
512
  }
294
- else if (this.yaml !== undefined) {
295
- result = parsePricingYaml(this.yaml, {
296
- ...(this.normalizeOptions ? { normalize: this.normalizeOptions } : {}),
513
+ if (sequence !== this._loadSequence || controller.signal.aborted) {
514
+ return;
515
+ }
516
+ this._applyLoadResult(result);
517
+ }
518
+ get resolvedState() {
519
+ return this._snapshot();
520
+ }
521
+ applySelection(selection) {
522
+ if (!this._isReady()) {
523
+ return this._refuseSelection(undefined, {
524
+ code: 'PR_SELECTION_NOT_READY',
525
+ severity: 'error',
526
+ message: 'The pricing is not ready; applySelection was ignored.',
297
527
  });
298
528
  }
299
- else {
300
- result = await loadPricingFromUrl(this.src, {
301
- ...(this.request ?? {}),
302
- ...(this.loadPricing ? { loadPricing: this.loadPricing } : {}),
303
- ...(this.normalizeOptions ? { normalize: this.normalizeOptions } : {}),
304
- signal: controller.signal,
529
+ if (this.selection !== undefined) {
530
+ return this._refuseSelection(this._snapshot(), {
531
+ code: 'PR_SELECTION_CONTROLLED',
532
+ severity: 'error',
533
+ message: 'The selection property is set; applySelection was ignored.',
305
534
  });
306
535
  }
307
- if (sequence !== this._loadSequence || controller.signal.aborted) {
536
+ const { removedAddOnIds, diagnostics, ...next } = applySelectionInput(this._normalized, this._selectionInEffect(), cloneValue(selection));
537
+ this._internalSelection = next.selection;
538
+ if (this._pendingAddOnChange)
539
+ this._cancelAddOnChange();
540
+ this.requestUpdate();
541
+ const state = this._snapshot();
542
+ const result = {
543
+ applied: true,
544
+ selection: state.selection,
545
+ resolved: state.resolved,
546
+ removedAddOnIds,
547
+ diagnostics,
548
+ };
549
+ this._dispatchSelectionDiagnostics(diagnostics);
550
+ return result;
551
+ }
552
+ _isReady() {
553
+ return this._status === 'ready' && this._normalized !== undefined;
554
+ }
555
+ _selectionInEffect() {
556
+ const selection = this._effectiveSelection();
557
+ if (!selection || !this._normalized)
558
+ return selection;
559
+ return reconcileSelection(this._normalized, selection).selection;
560
+ }
561
+ _snapshot() {
562
+ if (!this._isReady())
563
+ return undefined;
564
+ const selection = this._selectionInEffect();
565
+ if (!selection)
566
+ return undefined;
567
+ const { resolved } = this._priceStabilizer.stabilize(createPricingViewModel(this._normalized, selection, { ...this._viewModelOptions() }));
568
+ return cloneValue({ selection, resolved });
569
+ }
570
+ _refuseSelection(state, diagnostic) {
571
+ const result = {
572
+ applied: false,
573
+ selection: state?.selection,
574
+ resolved: state?.resolved,
575
+ removedAddOnIds: [],
576
+ diagnostics: [diagnostic],
577
+ };
578
+ this._dispatchSelectionDiagnostics(result.diagnostics);
579
+ return result;
580
+ }
581
+ // the rendered list is left unchanged
582
+ _dispatchSelectionDiagnostics(diagnostics) {
583
+ if (diagnostics.length === 0)
308
584
  return;
309
- }
310
- this._applyLoadResult(result);
585
+ this.dispatchEvent(new CustomEvent('pricing-diagnostic', {
586
+ detail: { diagnostics: cloneValue(diagnostics) },
587
+ bubbles: true,
588
+ composed: true,
589
+ }));
311
590
  }
312
591
  _applyLoadResult(result) {
313
592
  this._diagnostics = result.diagnostics;
314
593
  if (result.value) {
315
594
  this._normalized = result.value;
316
- this._lastValidPlanPrices = {};
317
- this._lastValidAddOnPrices = {};
318
- this._internalSelection = mergeSelection(result.value, this.defaultSelection);
595
+ this._priceStabilizer.reset();
596
+ this._internalSelection = reconcileSelection(result.value, mergeSelection(result.value, this.defaultSelection)).selection;
319
597
  this._expandedGroups = new Set();
598
+ // the add-on search and reveal state start over with a new pricing
599
+ this._addOnSearch = '';
600
+ this._addOnsRevealed = false;
601
+ this._onlyDifferencesOverride = undefined;
602
+ this._pendingAddOnChange = undefined;
603
+ this._previousFocus = undefined;
604
+ this._breakdownOpen = false;
320
605
  const viewModel = createPricingViewModel(result.value, this._effectiveSelection(), this._viewModelOptions());
321
606
  if (viewModel.comparisonGroups[0]) {
322
607
  this._expandedGroups.add(viewModel.comparisonGroups[0].id);
323
608
  }
324
609
  this._status = 'ready';
610
+ // Ready state and internal selection are set before the dispatch and not assigned after it,
611
+ // so a listener's applySelection call stays in effect for this load.
325
612
  this.dispatchEvent(new CustomEvent('pricing-ready', {
326
- detail: { pricing: result.value, diagnostics: result.diagnostics },
613
+ detail: { pricing: result.value, diagnostics: result.diagnostics, ...this._snapshot() },
327
614
  bubbles: true,
328
615
  composed: true,
329
616
  }));
@@ -340,14 +627,15 @@ export class PricingRendererElement extends LitElement {
340
627
  }
341
628
  this.requestUpdate();
342
629
  }
630
+ // the core treats only `false` as off, the element any
631
+ // falsy value
343
632
  _effectiveSelection() {
344
633
  const selection = this.selection ?? this._internalSelection;
345
- if (!selection || this.variablesEnabled || !this._normalized)
634
+ if (!selection || !this._normalized)
346
635
  return selection;
347
- return {
348
- ...selection,
349
- variables: cloneValue(this._normalized.variables),
350
- };
636
+ return selectionInEffect(this._normalized, selection, {
637
+ variablesEnabled: Boolean(this.variablesEnabled),
638
+ });
351
639
  }
352
640
  _viewModelOptions() {
353
641
  return {
@@ -359,56 +647,39 @@ export class PricingRendererElement extends LitElement {
359
647
  ...(this.expressionOptions ? { expression: this.expressionOptions } : {}),
360
648
  };
361
649
  }
362
- _stabilizeResolvedPrices(viewModel) {
363
- for (const [id, price] of Object.entries(viewModel.resolved.planPrices)) {
364
- if (price.kind === 'amount') {
365
- this._lastValidPlanPrices[id] = price;
366
- }
367
- else if (price.kind === 'error' && this._lastValidPlanPrices[id]) {
368
- viewModel.resolved.planPrices[id] = this._lastValidPlanPrices[id];
369
- }
370
- }
371
- for (const [id, price] of Object.entries(viewModel.resolved.addOnPrices)) {
372
- if (price.kind === 'amount') {
373
- this._lastValidAddOnPrices[id] = price;
374
- }
375
- else if (price.kind === 'error' && this._lastValidAddOnPrices[id]) {
376
- viewModel.resolved.addOnPrices[id] = this._lastValidAddOnPrices[id];
377
- }
378
- }
379
- const planPrice = viewModel.resolved.selection.planId
380
- ? viewModel.resolved.planPrices[viewModel.resolved.selection.planId]
381
- : undefined;
382
- let subtotal = planPrice?.kind === 'amount' ? planPrice.amount : 0;
383
- for (const [id, state] of Object.entries(viewModel.resolved.selection.addOns)) {
384
- const addOnPrice = viewModel.resolved.addOnPrices[id];
385
- if (state.selected && addOnPrice?.kind === 'amount') {
386
- subtotal += addOnPrice.amount;
387
- }
388
- }
389
- viewModel.resolved.subtotal = subtotal;
390
- return viewModel;
391
- }
392
650
  _viewModel() {
393
651
  if (!this._normalized)
394
652
  return undefined;
395
- return this._stabilizeResolvedPrices(createPricingViewModel(this._normalized, this._effectiveSelection(), {
653
+ return this._priceStabilizer.stabilize(createPricingViewModel(this._normalized, this._effectiveSelection(), {
396
654
  ...this._viewModelOptions(),
397
655
  }));
398
656
  }
657
+ // the relation between the current side and the selection in
658
+ // effect, both on the loaded pricing
659
+ _summaryAction(viewModel, pricing) {
660
+ const { relation } = compareSelections({
661
+ before: { pricing, selection: this._currentSelection },
662
+ after: { pricing, selection: viewModel.resolved.selection },
663
+ ...(this.expressionOptions ? { expression: this.expressionOptions } : {}),
664
+ });
665
+ return summaryActionState(viewModel, relation);
666
+ }
399
667
  _updateCompactLayout(width) {
400
668
  const next = this.layout === 'compact' ? true : this.layout === 'table' ? false : width < 840;
401
- if (next !== this._compact) {
669
+ const wide = width >= 840;
670
+ if (next !== this._compact || wide !== this._wide) {
402
671
  this._compact = next;
672
+ this._wide = wide;
403
673
  this.requestUpdate();
404
674
  }
405
675
  }
406
- _setSelection(next) {
676
+ _setSelection(input) {
677
+ const next = this._normalized ? reconcileSelection(this._normalized, input).selection : input;
407
678
  if (this.selection === undefined) {
408
679
  this._internalSelection = next;
409
680
  }
410
681
  const resolved = this._normalized
411
- ? this._stabilizeResolvedPrices(createPricingViewModel(this._normalized, next, {
682
+ ? this._priceStabilizer.stabilize(createPricingViewModel(this._normalized, next, {
412
683
  ...this._viewModelOptions(),
413
684
  })).resolved
414
685
  : undefined;
@@ -421,36 +692,18 @@ export class PricingRendererElement extends LitElement {
421
692
  }
422
693
  this.requestUpdate();
423
694
  }
424
- _collectDependencies(addOnId, collected = new Set()) {
425
- if (!this._normalized || collected.has(addOnId))
426
- return collected;
427
- const addOn = this._normalized.addOns.find((candidate) => candidate.id === addOnId);
428
- for (const dependency of addOn?.dependsOn ?? []) {
429
- if (!collected.has(dependency)) {
430
- collected.add(dependency);
431
- this._collectDependencies(dependency, collected);
432
- }
433
- }
434
- return collected;
435
- }
436
- _selectedDependents(addOnId) {
437
- if (!this._normalized)
438
- return [];
439
- const selection = this._effectiveSelection();
440
- const dependents = new Set();
441
- let changed = true;
442
- while (changed) {
443
- changed = false;
444
- for (const addOn of this._normalized.addOns) {
445
- if (!selection?.addOns[addOn.id]?.selected || dependents.has(addOn.id))
446
- continue;
447
- if (addOn.dependsOn.some((id) => id === addOnId || dependents.has(id))) {
448
- dependents.add(addOn.id);
449
- changed = true;
450
- }
451
- }
452
- }
453
- return [...dependents];
695
+ // Keeps the focused element for the cancel and confirm paths, then focuses the dialog's default
696
+ // button once it is rendered.
697
+ _openChangeDialog(pending) {
698
+ this._pendingAddOnChange = pending;
699
+ this._previousFocus =
700
+ this.ownerDocument.activeElement instanceof HTMLElement
701
+ ? this.ownerDocument.activeElement
702
+ : undefined;
703
+ this.requestUpdate();
704
+ void this.updateComplete.then(() => {
705
+ this.querySelector('.pr-dialog button[autofocus]')?.focus();
706
+ });
454
707
  }
455
708
  _applyAddOnChange(change) {
456
709
  if (!this._normalized)
@@ -458,35 +711,55 @@ export class PricingRendererElement extends LitElement {
458
711
  const selection = this._effectiveSelection();
459
712
  if (!selection)
460
713
  return;
461
- const addOns = { ...selection.addOns };
462
- const setState = (id, selected) => {
463
- const item = this._normalized.addOns.find((candidate) => candidate.id === id);
464
- const current = addOns[id];
465
- addOns[id] = {
466
- selected,
467
- quantity: current?.quantity ?? item?.subscriptionConstraints?.minQuantity ?? 1,
468
- };
469
- };
470
- setState(change.addOnId, change.select);
471
- change.addIds.forEach((id) => setState(id, true));
472
- change.removeIds.forEach((id) => setState(id, false));
714
+ const next = applySelectionChange(this._normalized, selection, change);
473
715
  this._pendingAddOnChange = undefined;
474
- this._setSelection({ ...selection, addOns });
716
+ this._setSelection(next);
475
717
  void this.updateComplete.then(() => this._restoreDialogFocus());
476
718
  }
477
719
  _restoreDialogFocus() {
478
720
  this._previousFocus?.focus();
479
721
  this._previousFocus = undefined;
480
722
  }
723
+ _focusRevealed(target, byKeyboard) {
724
+ if (!target.hasAttribute('tabindex')) {
725
+ target.setAttribute('tabindex', '-1');
726
+ // the attribute is only a focus aid for the reveal, so it goes away on blur
727
+ target.addEventListener('blur', () => target.removeAttribute('tabindex'), { once: true });
728
+ }
729
+ // Firefox keeps the cause of the earlier mouse click on the removed button and then skips
730
+ // :focus-visible for a script focus, so a keyboard activation asks for it explicitly.
731
+ // lib.dom has no focusVisible member in FocusOptions yet.
732
+ if (byKeyboard)
733
+ target.focus({ focusVisible: true });
734
+ else
735
+ target.focus();
736
+ }
737
+ // Escape closes the panel only from the toggle or from inside the panel; the dialog, which makes
738
+ // the rest inert, keeps its own Escape handling and leaves the panel as it is.
739
+ _closeBreakdownOnEscape(event) {
740
+ if (!this._breakdownOpen || event.key !== 'Escape')
741
+ return;
742
+ const target = event.target instanceof Element ? event.target : undefined;
743
+ const origin = target?.closest(`${breakdownToggleSelector}, ${breakdownPanelSelector}`);
744
+ if (!origin || !this.contains(origin))
745
+ return;
746
+ event.preventDefault();
747
+ this._breakdownOpen = false;
748
+ this.querySelector(breakdownToggleSelector)?.focus();
749
+ this.requestUpdate();
750
+ }
481
751
  render() {
752
+ const slots = requestedSlotsOf(this.slots);
482
753
  if (this._status === 'loading' || this._status === 'idle') {
754
+ const loadingTitleId = `${this._idPrefix}-loading-title`;
755
+ const loadingLabel = this.loadingLabel ?? translate(getMessages(this.locale, this.messages), 'pricing.loading');
756
+ if (slots.has('loading'))
757
+ return renderLoadingSlotShell(loadingTitleId, loadingLabel);
483
758
  return html `
484
- <section class="pr-shell pr-shell--loading" aria-labelledby="pr-loading-title">
485
- <h2 id="pr-loading-title" class="pr-sr-only">
486
- ${this.loadingLabel ?? 'Loading pricing'}
487
- </h2>
759
+ <section class="pr-shell pr-shell--loading" aria-labelledby=${loadingTitleId}>
760
+ <h2 id=${loadingTitleId} class="pr-sr-only">${loadingLabel}</h2>
488
761
  <div class="pr-skeleton" role="status" aria-live="polite">
489
- <span>${this.loadingLabel ?? 'Loading pricing…'}</span>
762
+ <span>${loadingLabel}</span>
490
763
  <div></div>
491
764
  <div></div>
492
765
  <div></div>
@@ -495,23 +768,23 @@ export class PricingRendererElement extends LitElement {
495
768
  `;
496
769
  }
497
770
  if (!this._normalized || this._status === 'error') {
498
- const messages = createPricingViewModel({
499
- metadata: { syntaxVersion: '3.1', saasName: 'Pricing', tags: [], currency: 'USD' },
500
- billing: [],
501
- variables: {},
502
- features: [],
503
- usageLimits: [],
504
- plans: [],
505
- addOns: [],
506
- custom: {},
507
- raw: {},
508
- }, undefined, { locale: this.locale, messages: this.messages }).messages;
771
+ if (slots.has('error'))
772
+ return renderErrorSlotShell(this._diagnostics);
773
+ const messages = getMessages(this.locale, this.messages);
774
+ // the codes and messages stay inside a closed details
509
775
  return html `
510
776
  <section class="pr-shell pr-error" role="alert">
511
777
  <h2>${translate(messages, 'pricing.error')}</h2>
512
- <ul>
513
- ${this._diagnostics.map((item) => html `<li><strong>${item.code}</strong>: ${item.message}</li>`)}
514
- </ul>
778
+ <p>${translate(messages, errorMessageKey(this._diagnostics))}</p>
779
+ <details class="pr-diagnostics" data-pr-part="diagnostics">
780
+ <summary>${translate(messages, 'pricing.errorDetails')}</summary>
781
+ <ul>
782
+ ${this._diagnostics.map((item) => html `<li>
783
+ <strong>${item.code}</strong>:
784
+ ${item.message}${item.path ? ` (${item.path})` : ''}
785
+ </li>`)}
786
+ </ul>
787
+ </details>
515
788
  ${this.src
516
789
  ? html `<button
517
790
  class="pr-button pr-button--primary"
@@ -525,44 +798,85 @@ export class PricingRendererElement extends LitElement {
525
798
  `;
526
799
  }
527
800
  const viewModel = this._viewModel();
801
+ const toolbarThreshold = toolbarThresholdOf(this.toolbarThreshold);
802
+ // Without a toolbar the user cannot undo a filter, so the search text and the checkbox override
803
+ // are ignored while it is hidden (the stored values are kept).
804
+ const toolbarShown = comparisonRowTotal(viewModel) > toolbarThreshold;
805
+ // a forced value is ignored without the toolbar,
806
+ // because no checkbox exists to undo it
807
+ const initialOnlyDifferences = this._onlyDifferencesOverride ??
808
+ resolveOnlyDifferences(viewModel, this.onlyDifferences, toolbarThreshold);
809
+ // the pricing, not the visible plans, defines it
810
+ const current = deriveCurrentContext(this._normalized, this._currentSelection);
811
+ const hidden = hiddenSectionsOf(this.hideSections);
528
812
  const state = {
529
813
  viewModel,
814
+ ...(hidden.size > 0 ? { hiddenSections: hidden } : {}),
815
+ ...(slots.size > 0 ? { slots } : {}),
530
816
  selection: viewModel.resolved.selection,
531
817
  compact: this._compact,
532
- search: this._search,
533
- onlyDifferences: this._onlyDifferences,
818
+ search: toolbarShown ? this._search : '',
819
+ onlyDifferences: toolbarShown && initialOnlyDifferences,
820
+ toolbarThreshold,
534
821
  selectionEnabled: this.selectionEnabled,
535
822
  ctaEnabled: this.ctaEnabled,
536
823
  variablesEnabled: this.variablesEnabled,
537
824
  expandedGroups: this._expandedGroups,
538
825
  revealedGroups: this._revealedGroups,
826
+ addOnSearch: this._addOnSearch,
827
+ addOnsRevealed: this._addOnsRevealed,
828
+ addOnFilterThreshold: addOnFilterThresholdOf(this.addOnFilterThreshold),
539
829
  ...(this._pendingAddOnChange ? { pendingAddOnChange: this._pendingAddOnChange } : {}),
830
+ breakdownOpen: this._breakdownOpen,
831
+ breakdownId: this._breakdownId,
832
+ idPrefix: this._idPrefix,
833
+ ...(current ? { currentPlanId: current.planId, currentAddOnIds: current.addOnIds } : {}),
834
+ ...(this.pendingActionId ? { pendingActionId: this.pendingActionId } : {}),
835
+ ...(current && this.ctaEnabled
836
+ ? { summaryAction: this._summaryAction(viewModel, this._normalized) }
837
+ : {}),
540
838
  actions: this._actions,
541
839
  };
542
- const warnings = this._diagnostics.filter((item) => item.severity !== 'info');
840
+ // auto, the default, keeps warnings out of the page: the pricing rendered, so they are a
841
+ // developer's concern and stay in the pricing-diagnostic event. Errors are shown because a
842
+ // visitor can see what they broke. verbose shows both; off shows none.
843
+ const shownDiagnostics = this.diagnostics === 'off'
844
+ ? []
845
+ : this.diagnostics === 'verbose'
846
+ ? this._diagnostics.filter((item) => item.severity !== 'info')
847
+ : this._diagnostics.filter((item) => item.severity === 'error');
543
848
  return html `
544
- <section class="pr-shell" aria-labelledby="pr-title">
849
+ <section class="pr-shell" aria-labelledby=${instanceId(state, 'title')}>
545
850
  <div
546
851
  class="pr-content"
547
852
  aria-hidden=${this._pendingAddOnChange ? 'true' : nothing}
548
853
  ?inert=${Boolean(this._pendingAddOnChange)}
549
854
  >
550
- ${warnings.length
855
+ ${shownDiagnostics.length
551
856
  ? html `<details class="pr-diagnostics" data-pr-part="diagnostics">
552
857
  <summary>
553
- ${translate(viewModel.messages, 'pricing.warning')} (${warnings.length})
858
+ ${translate(viewModel.messages, 'pricing.warning')} (${shownDiagnostics.length})
554
859
  </summary>
555
860
  <ul>
556
- ${warnings.map((item) => html `<li>
861
+ ${shownDiagnostics.map((item) => html `<li>
557
862
  <strong>${item.code}</strong>:
558
863
  ${item.message}${item.path ? ` (${item.path})` : ''}
559
864
  </li>`)}
560
865
  </ul>
561
866
  </details>`
562
867
  : nothing}
563
- ${renderHeader(state)} ${renderCatalogMetadata(state)} ${renderBilling(state)}
564
- ${renderVariables(state)} ${renderPlans(state)} ${renderComparison(state)}
565
- ${renderAddOns(state)} ${viewModel.addOns.length === 0 ? renderSummary(state) : nothing}
868
+ ${
869
+ // the shell stays labelled by the title when the hero is hidden
870
+ hidden.has('hero')
871
+ ? html `<h2 class="pr-sr-only" id=${instanceId(state, 'title')}>
872
+ ${heroTitle(viewModel)}
873
+ </h2>`
874
+ : renderHeader(state)}
875
+ ${hidden.has('metadata') ? nothing : renderCatalogMetadata(state)}
876
+ ${hidden.has('billing') ? nothing : renderBilling(state)}
877
+ ${hidden.has('variables') ? nothing : renderVariables(state)}
878
+ ${hidden.has('plans') ? nothing : renderPlans(state)}
879
+ ${renderAfterPlans(state, this._wide)}
566
880
  </div>
567
881
  ${renderPendingDialog(state)}
568
882
  <p class="pr-sr-only" aria-live="polite">
@@ -572,16 +886,26 @@ export class PricingRendererElement extends LitElement {
572
886
  `;
573
887
  }
574
888
  }
889
+ _a = PricingRendererElement;
575
890
  PricingRendererElement.properties = {
576
- pricing: { attribute: false },
891
+ // a host that builds these objects in its render
892
+ // function does not reload on every render; call reload() to force a load. loadPricing, yaml
893
+ // and src keep the identity compare.
894
+ pricing: { attribute: false, hasChanged: inputChanged },
577
895
  yaml: { attribute: false },
578
896
  src: { type: String, reflect: true },
579
- request: { attribute: false },
897
+ request: { attribute: false, hasChanged: inputChanged },
580
898
  loadPricing: { attribute: false },
581
- normalizeOptions: { attribute: false },
899
+ normalizeOptions: { attribute: false, hasChanged: inputChanged },
582
900
  expressionOptions: { attribute: false },
583
901
  selection: { attribute: false },
584
902
  defaultSelection: { attribute: false },
903
+ // the getter returns a copy, so the default identity check would see every set as a change
904
+ currentSelection: {
905
+ attribute: false,
906
+ hasChanged: (value, previous) => !deepEqual(value, previous),
907
+ },
908
+ pendingActionId: { type: String, attribute: 'pending-action-id' },
585
909
  presentation: { attribute: false },
586
910
  locale: { type: String },
587
911
  pricingPath: { type: String, attribute: 'pricing-path', reflect: true },
@@ -593,6 +917,28 @@ PricingRendererElement.properties = {
593
917
  layout: { type: String, reflect: true },
594
918
  visibility: { type: String, reflect: true },
595
919
  theme: { type: String, reflect: true },
920
+ // Which diagnostics reach the page. Warnings mean the pricing rendered anyway, so they are
921
+ // for whoever builds the page, not for a customer: "verbose" is the one that shows them.
922
+ diagnostics: { type: String, reflect: true },
923
+ // The accessor below is the class's own, so Lit reads it on the first update and would reflect
924
+ // density="comfortable" onto every host; useDefault records that value as the default instead.
925
+ density: { type: String, reflect: true, noAccessor: true, useDefault: true },
596
926
  loadingLabel: { type: String, attribute: 'loading-label' },
927
+ onlyDifferences: { attribute: 'only-differences', converter: onlyDifferencesConverter },
928
+ toolbarThreshold: { type: Number, attribute: 'toolbar-threshold' },
929
+ addOnFilterThreshold: { type: Number, attribute: 'add-on-filter-threshold' },
930
+ // a host that builds the array in its render function does
931
+ // not re-render on every render
932
+ hideSections: {
933
+ attribute: 'hide-sections',
934
+ converter: hideSectionsConverter,
935
+ hasChanged: (value, previous) => !deepEqual(value, previous),
936
+ },
937
+ // the slot names the host fills; no attribute
938
+ slots: { attribute: false },
597
939
  };
940
+ (() => {
941
+ // elementProperties is complete without defining the tag
942
+ _a.finalize();
943
+ })();
598
944
  //# sourceMappingURL=pricing-renderer-element.js.map