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,8 +1,78 @@
1
1
  import { html, nothing } from 'lit';
2
- import { deepEqual, formatCurrency, formatDate, getAtPath, getPlanCta, getPlanHighlightSummary, humanizeIdentifier, isSafeLink, translate, unwrapValue, } from '../core/index.js';
2
+ import { keyed as keyedBy } from 'lit/directives/keyed.js';
3
+ import { live } from 'lit/directives/live.js';
4
+ import { repeat } from 'lit/directives/repeat.js';
5
+ import { deepEqual, formatCurrency, formatDate, getAtPath, getPlanHighlightSummary, humanizeIdentifier, isSafeLink, normalizeAddOnQuantity, translate, unwrapValue, } from '../core/index.js';
6
+ import { maxQuantityOnGrid } from '../core/resolve.js';
7
+ import { blockingAddOnIds } from '../core/selection-change.js';
8
+ import { addOnProvidesForPlan, isAddOnOnlyRow } from '../core/view-model.js';
9
+ import { formatUnit } from '../core/units.js';
10
+ import { isPendingActionId, planCtaView } from './cta.js';
11
+ // the sections a host can hide
12
+ export const PRICING_SECTION_NAMES = [
13
+ 'hero',
14
+ 'metadata',
15
+ 'billing',
16
+ 'variables',
17
+ 'plans',
18
+ 'comparison',
19
+ 'addOns',
20
+ 'summary',
21
+ // Pieces of the hero, for a page that already has its own title or lead paragraph.
22
+ 'saasName',
23
+ 'description',
24
+ 'tags',
25
+ ];
26
+ // the slots a host can request
27
+ export const PRICING_SLOT_NAMES = ['plan-cta', 'summary-actions', 'loading', 'error'];
28
+ // ids are global in the light DOM, so every id carries the
29
+ // instance prefix. The data part (a path, a group or add-on id) keeps [A-Za-z0-9]; every other
30
+ // code point becomes _<lowercase hex>_, so distinct data never yields the same id.
31
+ export function instanceId(state, name, data) {
32
+ const base = `${state.idPrefix ?? 'pr'}-${name}`;
33
+ if (data === undefined)
34
+ return base;
35
+ const escaped = data.replace(/[^A-Za-z0-9]/gu, (char) => `_${char.codePointAt(0)?.toString(16)}_`);
36
+ return `${base}-${escaped}`;
37
+ }
38
+ export const DEFAULT_TOOLBAR_THRESHOLD = 40;
39
+ export const AUTO_DIFFERENCES_MIN_ROWS = 100;
40
+ export const ADDON_DENSE_THRESHOLD = 8;
41
+ export const ADDON_VISIBLE_CAP = 8;
42
+ export const DEFAULT_ADDON_FILTER_THRESHOLD = 13;
43
+ export function comparisonRowTotal(viewModel) {
44
+ return viewModel.comparisonGroups.reduce((total, group) => total + group.rows.length, 0);
45
+ }
46
+ // a missing, non-finite or negative value gives 40
47
+ export function toolbarThresholdOf(value) {
48
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
49
+ ? value
50
+ : DEFAULT_TOOLBAR_THRESHOLD;
51
+ }
52
+ // a missing, non-finite or negative value gives 13
53
+ export function addOnFilterThresholdOf(value) {
54
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
55
+ ? value
56
+ : DEFAULT_ADDON_FILTER_THRESHOLD;
57
+ }
58
+ // An explicit initial state wins. Otherwise a long comparison with several plans starts with only
59
+ // the differences, as long as the toolbar is there to switch the filter off.
60
+ export function resolveOnlyDifferences(viewModel, initial, toolbarThreshold = DEFAULT_TOOLBAR_THRESHOLD) {
61
+ if (initial !== undefined)
62
+ return initial;
63
+ const total = comparisonRowTotal(viewModel);
64
+ return (total > AUTO_DIFFERENCES_MIN_ROWS &&
65
+ viewModel.plans.length >= 2 &&
66
+ total > toolbarThresholdOf(toolbarThreshold));
67
+ }
3
68
  function t(viewModel, key, parameters) {
4
69
  return translate(viewModel.messages, key, parameters);
5
70
  }
71
+ function addOnNames(viewModel, ids) {
72
+ return ids
73
+ .map((id) => viewModel.addOns.find((addOn) => addOn.id === id)?.name ?? humanizeIdentifier(id))
74
+ .join(', ');
75
+ }
6
76
  const interactiveCardContent = 'a, button, input, select, textarea, label, summary, details, [contenteditable="true"]';
7
77
  function selectFromCard(event, select) {
8
78
  if (event.defaultPrevented || event.button !== 0)
@@ -28,7 +98,7 @@ function renderPrice(viewModel, price, unit) {
28
98
  : nothing}
29
99
  `;
30
100
  }
31
- function renderValue(viewModel, value, unit) {
101
+ function renderValue(viewModel, value, unit, addOnOnly = false) {
32
102
  if (value === true) {
33
103
  return html `<span class="pr-value pr-value--included"
34
104
  ><span aria-hidden="true">✓</span
@@ -36,6 +106,12 @@ function renderValue(viewModel, value, unit) {
36
106
  >`;
37
107
  }
38
108
  if (value === false || value === null || value === undefined) {
109
+ if (addOnOnly) {
110
+ return html `<span class="pr-value pr-value--addon"
111
+ ><span aria-hidden="true">${t(viewModel, 'pricing.addOnOnly')}</span
112
+ ><span class="pr-sr-only">${t(viewModel, 'pricing.addOnOnlySr')}</span></span
113
+ >`;
114
+ }
39
115
  return html `<span class="pr-value pr-value--excluded"
40
116
  ><span aria-hidden="true">—</span
41
117
  ><span class="pr-sr-only">${t(viewModel, 'pricing.notIncluded')}</span></span
@@ -49,7 +125,10 @@ function renderValue(viewModel, value, unit) {
49
125
  }
50
126
  if (typeof value === 'number') {
51
127
  const formatted = new Intl.NumberFormat(viewModel.locale).format(value);
52
- return html `<span class="pr-value">${formatted}${unit ? ` ${unit}` : ''}</span>`;
128
+ const label = unit
129
+ ? ` ${formatUnit(unit, value, viewModel.locale, viewModel.presentation.unitLabels)}`
130
+ : '';
131
+ return html `<span class="pr-value">${formatted}${label}</span>`;
53
132
  }
54
133
  if (Array.isArray(value)) {
55
134
  return html `<span class="pr-value">${value.map(String).join(', ')}</span>`;
@@ -63,6 +142,13 @@ function resolvedValuesDiffer(row) {
63
142
  const values = row.values.map((cell) => cell.value);
64
143
  return values.some((value) => !deepEqual(value, values[0]));
65
144
  }
145
+ // A description that is empty or only repeats the row name adds nothing, so it is not rendered.
146
+ function rowDescription(row) {
147
+ const description = row.description?.trim();
148
+ if (!description)
149
+ return undefined;
150
+ return description.toLowerCase() === row.name.trim().toLowerCase() ? undefined : row.description;
151
+ }
66
152
  function renderRowMetadata(viewModel, row) {
67
153
  if (viewModel.mode !== 'catalog')
68
154
  return nothing;
@@ -79,18 +165,30 @@ function renderRowMetadata(viewModel, row) {
79
165
  ? html `<span class="pr-row-metadata">${metadata.join(' · ')}</span>`
80
166
  : nothing;
81
167
  }
168
+ // also the text of the title that stays when the hero is hidden
169
+ export function heroTitle(viewModel) {
170
+ return (viewModel.presentation.title ??
171
+ viewModel.pricing.metadata.saasName ??
172
+ t(viewModel, 'pricing.title'));
173
+ }
82
174
  export function renderHeader(state) {
83
175
  const { viewModel } = state;
84
- const title = viewModel.presentation.title ??
85
- viewModel.pricing.metadata.saasName ??
86
- t(viewModel, 'pricing.title');
176
+ const hidden = state.hiddenSections;
177
+ const title = heroTitle(viewModel);
87
178
  const subtitle = viewModel.presentation.subtitle ?? t(viewModel, 'pricing.description');
179
+ // The title carries the id that labels the shell, so hiding the name keeps it for screen
180
+ // readers instead of dropping the element.
181
+ const titleHidden = hidden?.has('saasName') === true;
88
182
  return html `
89
183
  <header class="pr-hero" data-pr-part="hero">
90
- <div class="pr-hero__eyebrow">${t(viewModel, 'pricing.title')}</div>
91
- <h2 class="pr-hero__title" id="pr-title">${title}</h2>
92
- ${subtitle ? html `<p class="pr-hero__subtitle">${subtitle}</p>` : nothing}
93
- ${viewModel.pricing.metadata.tags.length
184
+ ${titleHidden ? nothing : html `<div class="pr-hero__eyebrow">${t(viewModel, 'pricing.title')}</div>`}
185
+ <h2 class=${titleHidden ? 'pr-sr-only' : 'pr-hero__title'} id=${instanceId(state, 'title')}>
186
+ ${title}
187
+ </h2>
188
+ ${subtitle && hidden?.has('description') !== true
189
+ ? html `<p class="pr-hero__subtitle">${subtitle}</p>`
190
+ : nothing}
191
+ ${viewModel.pricing.metadata.tags.length && hidden?.has('tags') !== true
94
192
  ? html `<ul class="pr-tag-list" aria-label="${t(viewModel, 'pricing.tags')}">
95
193
  ${viewModel.pricing.metadata.tags.map((tag) => html `<li class="pr-tag">${tag}</li>`)}
96
194
  </ul>`
@@ -113,7 +211,7 @@ export function renderBilling(state) {
113
211
  <label class="pr-segmented__option">
114
212
  <input
115
213
  type="radio"
116
- name="pr-billing"
214
+ name=${instanceId(state, 'billing')}
117
215
  value=${period.id}
118
216
  .checked=${selection.billingPeriod === period.id}
119
217
  @change=${() => actions.selectBilling(period.id)}
@@ -129,14 +227,105 @@ export function renderBilling(state) {
129
227
  </fieldset>
130
228
  `;
131
229
  }
230
+ // the pending text replaces the label of the busy control
231
+ function renderCtaLabel(viewModel, label, pending) {
232
+ return pending
233
+ ? html `<span data-pr-part="cta-pending">${t(viewModel, 'pricing.pending')}</span>`
234
+ : label;
235
+ }
236
+ // aria-disabled replaces disabled while an action is pending, so
237
+ // the control that became pending keeps the focus
238
+ function renderPlanCta(state, plan) {
239
+ const { viewModel, actions } = state;
240
+ const cta = planCtaView(viewModel, plan, {
241
+ currentPlanId: state.currentPlanId,
242
+ pendingActionId: state.pendingActionId,
243
+ });
244
+ // data-kind agrees with the pr-button--<kind> class
245
+ const kind = cta.kind === 'secondary' ? 'secondary' : 'primary';
246
+ const origin = cta.relation
247
+ ? { relation: cta.relation, source: 'plan' }
248
+ : undefined;
249
+ const content = renderCtaLabel(viewModel, cta.label, cta.pending);
250
+ // The plan the visitor is already on has nothing to press. A disabled button invites a click
251
+ // that will never work and repeats the "your plan" badge in the shape of an action, so the
252
+ // slot keeps its height with plain text instead.
253
+ if (cta.disabled && cta.relation === 'current') {
254
+ return html `<p
255
+ class="pr-plan-card__current"
256
+ data-pr-part="cta"
257
+ data-kind=${kind}
258
+ data-relation="current"
259
+ >
260
+ ${content}
261
+ </p>`;
262
+ }
263
+ const trigger = () => actions.triggerAction(cta.actionId, plan.id, cta.eventHref, cta.metadata, origin);
264
+ return cta.href
265
+ ? html `<a
266
+ class="pr-button pr-button--${kind}"
267
+ data-pr-part="cta"
268
+ data-kind=${kind}
269
+ data-relation=${cta.relation ?? nothing}
270
+ data-pending=${cta.pending ? 'true' : nothing}
271
+ aria-busy=${cta.pending ? 'true' : nothing}
272
+ aria-disabled=${cta.ariaDisabled ? 'true' : nothing}
273
+ aria-label=${cta.ariaLabel ?? nothing}
274
+ href=${cta.href}
275
+ target=${cta.target ?? '_self'}
276
+ rel=${cta.target === '_blank' ? 'noopener noreferrer' : nothing}
277
+ @click=${(event) => {
278
+ if (!trigger())
279
+ event.preventDefault();
280
+ }}
281
+ >${content}</a
282
+ >`
283
+ : html `<button
284
+ class="pr-button pr-button--${kind}"
285
+ data-pr-part="cta"
286
+ data-kind=${kind}
287
+ data-relation=${cta.relation ?? nothing}
288
+ data-pending=${cta.pending ? 'true' : nothing}
289
+ aria-busy=${cta.pending ? 'true' : nothing}
290
+ aria-disabled=${cta.ariaDisabled ? 'true' : nothing}
291
+ aria-label=${cta.ariaLabel ?? nothing}
292
+ type="button"
293
+ ?disabled=${cta.disabled}
294
+ @click=${trigger}
295
+ >
296
+ ${content}
297
+ </button>`;
298
+ }
299
+ // An empty node the host fills; it carries the state of the built-in CTA and card as data-*. The
300
+ // keyed directive gives each plan its own node, so a node never moves to another plan.
301
+ function renderPlanCtaSlot(state, plan, selected, current) {
302
+ const cta = planCtaView(state.viewModel, plan, {
303
+ currentPlanId: state.currentPlanId,
304
+ pendingActionId: state.pendingActionId,
305
+ });
306
+ return keyedBy(plan.id, html `<div
307
+ data-pr-slot="plan-cta"
308
+ data-pr-part="plan-cta-slot"
309
+ data-plan-id=${plan.id}
310
+ data-plan-name=${plan.name}
311
+ data-label=${cta.label}
312
+ data-href=${cta.href ?? nothing}
313
+ data-relation=${cta.relation ?? nothing}
314
+ data-selected=${String(selected)}
315
+ data-current=${current ? 'true' : nothing}
316
+ data-pending=${cta.pending ? 'true' : nothing}
317
+ data-any-pending=${cta.ariaDisabled ? 'true' : nothing}
318
+ ></div>`);
319
+ }
132
320
  export function renderPlans(state) {
133
321
  const { viewModel, selection, selectionEnabled, ctaEnabled, actions } = state;
322
+ const titleId = instanceId(state, 'plans-title');
134
323
  return html `
135
- <section class="pr-section" aria-labelledby="pr-plans-title" data-pr-part="plans">
324
+ <section class="pr-section" aria-labelledby=${titleId} data-pr-part="plans">
136
325
  <div class="pr-section__heading">
137
326
  <div>
138
327
  <p class="pr-section__eyebrow">${t(viewModel, 'pricing.plans')}</p>
139
- <h3 id="pr-plans-title">${t(viewModel, 'pricing.plans')}</h3>
328
+ <h3 id=${titleId}>${t(viewModel, 'pricing.plansTitle')}</h3>
140
329
  </div>
141
330
  </div>
142
331
  ${viewModel.plans.length === 0
@@ -144,28 +333,37 @@ export function renderPlans(state) {
144
333
  : html `<div
145
334
  class="pr-plan-grid"
146
335
  role=${selectionEnabled ? 'radiogroup' : nothing}
147
- aria-labelledby=${selectionEnabled ? 'pr-plans-title' : nothing}
336
+ aria-labelledby=${selectionEnabled ? titleId : nothing}
148
337
  >
149
338
  ${viewModel.plans.map((plan) => {
150
339
  const selected = selection.planId === plan.id;
151
- const cta = getPlanCta(viewModel.presentation, plan.id);
340
+ const current = state.currentPlanId === plan.id;
152
341
  const highlightSummary = getPlanHighlightSummary(viewModel, plan.id);
153
342
  const featured = highlightSummary.badges.some((badge) => badge.emphasize);
154
- const actionId = cta?.id ?? `choose-${plan.id}`;
155
343
  return html `
156
344
  <article
157
345
  class="pr-plan-card ${selectionEnabled ? 'pr-selectable-card' : ''} ${selected ? 'is-selected' : ''} ${featured ? 'is-featured' : ''}"
158
346
  data-pr-part="plan-card"
159
347
  data-plan-id=${plan.id}
160
348
  data-selected=${String(selected)}
349
+ data-current=${current ? 'true' : nothing}
350
+ data-featured=${String(featured)}
161
351
  @click=${(event) => selectionEnabled
162
352
  ? selectFromCard(event, () => actions.selectPlan(plan.id))
163
353
  : undefined}
164
354
  >
165
355
  <div class="pr-plan-card__top">
166
- ${highlightSummary.badges.length > 0 || selectionEnabled
356
+ ${highlightSummary.badges.length > 0 || selectionEnabled || current
167
357
  ? html `<div class="pr-plan-card__status">
168
358
  <div class="pr-plan-card__badges">
359
+ ${current
360
+ ? html `<span
361
+ class="pr-badge"
362
+ data-pr-part="current-badge"
363
+ data-tone="success"
364
+ >${t(viewModel, 'pricing.currentPlanBadge')}</span
365
+ >`
366
+ : nothing}
169
367
  ${highlightSummary.badges.map((badge) => html `<span
170
368
  class="pr-badge"
171
369
  data-pr-part="plan-badge"
@@ -174,19 +372,21 @@ export function renderPlans(state) {
174
372
  >${badge.label}</span
175
373
  >`)}
176
374
  </div>
177
- ${selectionEnabled
375
+ ${
376
+ // live() resets the radio on cancel
377
+ selectionEnabled
178
378
  ? html `<label
179
379
  class="pr-selection-control pr-plan-card__selector"
180
380
  data-pr-part="selection-control"
181
381
  >
182
382
  <input
183
383
  type="radio"
184
- name="pr-plan"
384
+ name=${instanceId(state, 'plan')}
185
385
  value=${plan.id}
186
386
  aria-label=${t(viewModel, 'pricing.choosePlan', {
187
387
  plan: plan.name,
188
388
  })}
189
- .checked=${selected}
389
+ .checked=${live(selected)}
190
390
  @change=${() => actions.selectPlan(plan.id)}
191
391
  />
192
392
  <span
@@ -195,7 +395,13 @@ export function renderPlans(state) {
195
395
  ></span>
196
396
  <span class="pr-selection-control__label" aria-hidden="true"
197
397
  >${selected
198
- ? t(viewModel, 'pricing.selected')
398
+ ? // On the contract's own plan the "your plan" badge
399
+ // already says it, and the radio is visibly checked:
400
+ // a third "Selected" adds nothing. It comes back as
401
+ // soon as the visitor selects a different plan.
402
+ current
403
+ ? nothing
404
+ : t(viewModel, 'pricing.selected')
199
405
  : t(viewModel, 'pricing.select')}</span
200
406
  >
201
407
  </label>`
@@ -225,7 +431,14 @@ export function renderPlans(state) {
225
431
  <span class="pr-highlight-list__icon" aria-hidden="true">✓</span>
226
432
  <span>${row.name}</span>
227
433
  <span class="pr-highlight-list__value"
228
- >${renderValue(viewModel, value, row.unit)}</span
434
+ >${
435
+ // The bullet already means "included", so a plain true adds
436
+ // no visible information; only its screen-reader text stays.
437
+ value === true
438
+ ? html `<span class="pr-sr-only"
439
+ >${t(viewModel, 'pricing.included')}</span
440
+ >`
441
+ : renderValue(viewModel, value, row.unit)}</span
229
442
  >
230
443
  </li>`;
231
444
  })}
@@ -233,32 +446,9 @@ export function renderPlans(state) {
233
446
  : nothing}
234
447
  ${!ctaEnabled
235
448
  ? nothing
236
- : cta?.href && isSafeLink(cta.href)
237
- ? html `<a
238
- class="pr-button ${cta.kind === 'secondary'
239
- ? 'pr-button--secondary'
240
- : 'pr-button--primary'}"
241
- data-pr-part="cta"
242
- href=${cta.href}
243
- target=${cta.target ?? '_self'}
244
- rel=${cta.target === '_blank' ? 'noopener noreferrer' : nothing}
245
- @click=${(event) => {
246
- if (!actions.triggerAction(actionId, plan.id, cta.href, cta.metadata)) {
247
- event.preventDefault();
248
- }
249
- }}
250
- >${cta.label}</a
251
- >`
252
- : html `<button
253
- class="pr-button ${cta?.kind === 'secondary'
254
- ? 'pr-button--secondary'
255
- : 'pr-button--primary'}"
256
- data-pr-part="cta"
257
- type="button"
258
- @click=${() => actions.triggerAction(actionId, plan.id, cta?.href, cta?.metadata)}
259
- >
260
- ${cta?.label ?? t(viewModel, 'pricing.choosePlan', { plan: plan.name })}
261
- </button>`}
449
+ : state.slots?.has('plan-cta')
450
+ ? renderPlanCtaSlot(state, plan, selected, current)
451
+ : renderPlanCta(state, plan)}
262
452
  </article>
263
453
  `;
264
454
  })}
@@ -269,12 +459,16 @@ export function renderPlans(state) {
269
459
  function controlInput(state, control, invalid) {
270
460
  const { selection, actions } = state;
271
461
  const value = getAtPath(selection.variables, control.path);
272
- const id = `pr-variable-${control.path.replace(/[^\w-]+/g, '-')}`;
462
+ const id = instanceId(state, 'variable', control.path);
463
+ const describedBy = [control.description ? `${id}-help` : '', invalid ? `${id}-error` : '']
464
+ .filter(Boolean)
465
+ .join(' ') || nothing;
273
466
  if (control.type === 'boolean') {
274
467
  return html `<label class="pr-switch" for=${id}>
275
468
  <input
276
- id=${id}
469
+ id="${id}"
277
470
  type="checkbox"
471
+ aria-describedby="${describedBy}"
278
472
  aria-invalid=${invalid}
279
473
  .checked=${Boolean(value)}
280
474
  @change=${(event) => actions.updateVariable(control.path, event.currentTarget.checked)}
@@ -284,7 +478,8 @@ function controlInput(state, control, invalid) {
284
478
  }
285
479
  if (control.type === 'select') {
286
480
  return html `<select
287
- id=${id}
481
+ id="${id}"
482
+ aria-describedby="${describedBy}"
288
483
  aria-invalid=${invalid}
289
484
  .value=${String(value ?? '')}
290
485
  @change=${(event) => {
@@ -302,8 +497,9 @@ function controlInput(state, control, invalid) {
302
497
  const numericValue = typeof value === 'number' ? value : Number(value) || 0;
303
498
  return html `<div class="pr-slider">
304
499
  <input
305
- id=${id}
500
+ id="${id}"
306
501
  type="range"
502
+ aria-describedby="${describedBy}"
307
503
  aria-invalid=${invalid}
308
504
  .value=${String(numericValue)}
309
505
  min=${control.min ?? 0}
@@ -313,6 +509,7 @@ function controlInput(state, control, invalid) {
313
509
  />
314
510
  <input
315
511
  type="number"
512
+ aria-describedby="${describedBy}"
316
513
  aria-invalid=${invalid}
317
514
  aria-label=${control.label ?? control.path}
318
515
  .value=${String(numericValue)}
@@ -325,7 +522,8 @@ function controlInput(state, control, invalid) {
325
522
  }
326
523
  const isNumber = control.type === 'number';
327
524
  return html `<input
328
- id=${id}
525
+ id="${id}"
526
+ aria-describedby="${describedBy}"
329
527
  aria-invalid=${invalid}
330
528
  type=${isNumber ? 'number' : 'text'}
331
529
  .value=${String(value ?? '')}
@@ -342,17 +540,18 @@ export function renderVariables(state) {
342
540
  const { viewModel, variablesEnabled, actions } = state;
343
541
  if (!variablesEnabled || viewModel.variableControls.length === 0)
344
542
  return nothing;
543
+ const titleId = instanceId(state, 'variables-title');
345
544
  return html `
346
- <section class="pr-configurator" aria-labelledby="pr-variables-title" data-pr-part="variables">
545
+ <section class="pr-configurator" aria-labelledby=${titleId} data-pr-part="variables">
347
546
  <div class="pr-section__heading">
348
- <h3 id="pr-variables-title">${t(viewModel, 'pricing.variables')}</h3>
547
+ <h3 id=${titleId}>${t(viewModel, 'pricing.variables')}</h3>
349
548
  <button class="pr-button pr-button--quiet" type="button" @click=${actions.resetVariables}>
350
549
  ${t(viewModel, 'pricing.reset')}
351
550
  </button>
352
551
  </div>
353
552
  <div class="pr-variable-grid">
354
553
  ${viewModel.variableControls.map((control) => {
355
- const id = `pr-variable-${control.path.replace(/[^\w-]+/g, '-')}`;
554
+ const id = instanceId(state, 'variable', control.path);
356
555
  const invalid = viewModel.resolved.diagnostics.some((item) => {
357
556
  const planMatch = item.path?.match(/^plans\.([^.]+)\.price$/)?.[1];
358
557
  const addOnMatch = item.path?.match(/^addOns\.([^.]+)\.price$/)?.[1];
@@ -369,7 +568,7 @@ export function renderVariables(state) {
369
568
  ${control.description ? html `<p id="${id}-help">${control.description}</p>` : nothing}
370
569
  ${controlInput(state, control, invalid)}
371
570
  ${invalid
372
- ? html `<p class="pr-field__error" role="alert">
571
+ ? html `<p id="${id}-error" class="pr-field__error" role="alert">
373
572
  ${t(viewModel, 'pricing.variableError')}
374
573
  </p>`
375
574
  : nothing}
@@ -397,10 +596,24 @@ function filterGroups(state) {
397
596
  function visibleRows(state, group) {
398
597
  return state.revealedGroups.has(group.id) ? group.rows : group.rows.slice(0, 30);
399
598
  }
400
- function renderComparisonToolbar(state, totalRows) {
401
- if (state.viewModel.comparisonGroups.flatMap((group) => group.rows).length <= 40)
402
- return nothing;
599
+ function isHeaderless(group) {
600
+ return group.kind === 'untagged';
601
+ }
602
+ // a group with no header cannot be toggled
603
+ function isGroupExpanded(state, group) {
604
+ return isHeaderless(group) || state.expandedGroups.has(group.id) || Boolean(state.search);
605
+ }
606
+ // The rows that are on the page: those of expanded groups, after the cap per group.
607
+ function renderedRowCount(state, groups) {
608
+ return groups.reduce((count, group) => count + (isGroupExpanded(state, group) ? visibleRows(state, group).length : 0), 0);
609
+ }
610
+ function renderComparisonToolbar(state, groups) {
403
611
  const { viewModel, actions } = state;
612
+ const total = comparisonRowTotal(viewModel);
613
+ // a short comparison gets no toolbar and looks as before
614
+ if (total <= toolbarThresholdOf(state.toolbarThreshold))
615
+ return nothing;
616
+ const shown = renderedRowCount(state, groups);
404
617
  return html `
405
618
  <div class="pr-comparison-toolbar" data-pr-part="comparison-toolbar">
406
619
  <label class="pr-search">
@@ -420,15 +633,17 @@ function renderComparisonToolbar(state, totalRows) {
420
633
  />
421
634
  <span>${t(viewModel, 'pricing.onlyDifferences')}</span>
422
635
  </label>
423
- <div class="pr-toolbar-actions">
424
- <button type="button" @click=${() => actions.setAllGroups(true)}>
425
- ${t(viewModel, 'pricing.expandAll')}
426
- </button>
427
- <button type="button" @click=${() => actions.setAllGroups(false)}>
428
- ${t(viewModel, 'pricing.collapseAll')}
429
- </button>
430
- </div>
431
- <span aria-live="polite">${t(viewModel, 'pricing.rowsShown', { count: totalRows })}</span>
636
+ ${viewModel.comparisonGroups.every(isHeaderless)
637
+ ? nothing
638
+ : html `<div class="pr-toolbar-actions">
639
+ <button type="button" @click=${() => actions.setAllGroups(true)}>
640
+ ${t(viewModel, 'pricing.expandAll')}
641
+ </button>
642
+ <button type="button" @click=${() => actions.setAllGroups(false)}>
643
+ ${t(viewModel, 'pricing.collapseAll')}
644
+ </button>
645
+ </div>`}
646
+ <span aria-live="polite">${t(viewModel, 'pricing.rowsShownOf', { shown, total })}</span>
432
647
  </div>
433
648
  `;
434
649
  }
@@ -436,50 +651,58 @@ function renderCompactComparison(state, groups) {
436
651
  const { viewModel, actions } = state;
437
652
  return html `<div class="pr-compact-comparison">
438
653
  ${groups.map((group) => {
439
- const expanded = state.expandedGroups.has(group.id) || Boolean(state.search);
654
+ const expanded = isGroupExpanded(state, group);
440
655
  const rows = visibleRows(state, group);
441
656
  return html `
442
657
  <section class="pr-comparison-group">
443
- <h4>
444
- <button
445
- type="button"
446
- aria-expanded=${expanded}
447
- aria-controls="pr-group-${group.id}"
448
- @click=${() => actions.toggleGroup(group.id)}
449
- >
450
- <span>${group.name}</span><span aria-hidden="true">${expanded ? '−' : '+'}</span>
451
- </button>
452
- </h4>
658
+ ${isHeaderless(group)
659
+ ? nothing
660
+ : html `<h4>
661
+ <button
662
+ type="button"
663
+ aria-expanded=${expanded}
664
+ aria-controls=${expanded ? instanceId(state, 'group', group.id) : nothing}
665
+ @click=${() => actions.toggleGroup(group.id)}
666
+ >
667
+ <span>${group.name}</span
668
+ ><span aria-hidden="true">${expanded ? '−' : '+'}</span>
669
+ </button>
670
+ </h4>`}
453
671
  ${expanded
454
- ? html `<div id="pr-group-${group.id}">
455
- ${rows.map((row) => html `
672
+ ? html `<div id="${instanceId(state, 'group', group.id)}">
673
+ ${rows.map((row) => {
674
+ const addOnOnly = isAddOnOnlyRow(viewModel, row);
675
+ return html `
456
676
  <article class="pr-feature-card" data-pr-part="comparison-row">
457
677
  <div>
458
678
  <h5>${row.name}</h5>
459
- ${row.description ? html `<p>${row.description}</p>` : nothing}
679
+ ${rowDescription(row) ? html `<p>${rowDescription(row)}</p>` : nothing}
460
680
  ${renderRowMetadata(viewModel, row)}
461
681
  ${row.docUrl && isSafeLink(row.docUrl)
462
- ? html `<a href=${row.docUrl} target="_blank" rel="noopener noreferrer"
682
+ ? html `<a href=${row.docUrl} target="_blank" rel="noopener noreferrer"
463
683
  >${t(viewModel, 'pricing.documentation')}</a
464
684
  >`
465
- : nothing}
685
+ : nothing}
466
686
  </div>
467
687
  <dl>
468
688
  ${row.values.map((cell) => {
469
- const plan = viewModel.plans.find((candidate) => candidate.id === cell.planId);
470
- return html `<div>
689
+ const plan = viewModel.plans.find((candidate) => candidate.id === cell.planId);
690
+ return html `<div>
471
691
  <dt>${plan?.name ?? cell.planId}</dt>
472
- <dd>${renderValue(viewModel, cell.value, row.unit)}</dd>
692
+ <dd>
693
+ ${renderValue(viewModel, cell.value, row.unit, addOnOnly && addOnProvidesForPlan(viewModel, row, cell.planId))}
694
+ </dd>
473
695
  </div>`;
474
- })}
696
+ })}
475
697
  </dl>
476
698
  </article>
477
- `)}
699
+ `;
700
+ })}
478
701
  ${group.rows.length > rows.length
479
702
  ? html `<button
480
703
  class="pr-button pr-button--quiet"
481
704
  type="button"
482
- @click=${() => actions.revealGroup(group.id)}
705
+ @click=${(event) => actions.revealGroup(group.id, event.currentTarget, event.detail === 0)}
483
706
  >
484
707
  ${t(viewModel, 'pricing.showRemaining', {
485
708
  count: group.rows.length - rows.length,
@@ -513,33 +736,42 @@ function renderTableComparison(state, groups) {
513
736
  </tr>
514
737
  </thead>
515
738
  ${groups.map((group) => {
516
- const expanded = state.expandedGroups.has(group.id) || Boolean(state.search);
739
+ const expanded = isGroupExpanded(state, group);
517
740
  const rows = visibleRows(state, group);
518
741
  return html `
519
742
  <tbody>
520
- <tr class="pr-category-row">
521
- <th colspan=${viewModel.plans.length + 1}>
522
- <button
523
- type="button"
524
- aria-expanded=${expanded}
525
- @click=${() => actions.toggleGroup(group.id)}
526
- >
527
- <span>${group.name}</span
528
- ><span aria-hidden="true">${expanded ? '−' : '+'}</span>
529
- </button>
530
- </th>
531
- </tr>
743
+ ${isHeaderless(group)
744
+ ? nothing
745
+ : html `<tr class="pr-category-row">
746
+ <th colspan=${viewModel.plans.length + 1}>
747
+ <button
748
+ type="button"
749
+ aria-expanded=${expanded}
750
+ @click=${() => actions.toggleGroup(group.id)}
751
+ >
752
+ <span>${group.name}</span
753
+ ><span aria-hidden="true">${expanded ? '−' : '+'}</span>
754
+ </button>
755
+ </th>
756
+ </tr>`}
532
757
  ${expanded
533
- ? rows.map((row) => html `
758
+ ? rows.map((row) => {
759
+ const addOnOnly = isAddOnOnlyRow(viewModel, row);
760
+ return html `
534
761
  <tr data-pr-part="comparison-row">
535
762
  <th scope="row">
536
763
  <span>${row.name}</span>
537
- ${row.description ? html `<small>${row.description}</small>` : nothing}
764
+ ${rowDescription(row)
765
+ ? html `<small>${rowDescription(row)}</small>`
766
+ : nothing}
538
767
  ${renderRowMetadata(viewModel, row)}
539
768
  </th>
540
- ${row.values.map((cell) => html `<td>${renderValue(viewModel, cell.value, row.unit)}</td>`)}
769
+ ${row.values.map((cell) => html `<td>
770
+ ${renderValue(viewModel, cell.value, row.unit, addOnOnly && addOnProvidesForPlan(viewModel, row, cell.planId))}
771
+ </td>`)}
541
772
  </tr>
542
- `)
773
+ `;
774
+ })
543
775
  : nothing}
544
776
  ${expanded && group.rows.length > rows.length
545
777
  ? html `<tr>
@@ -547,7 +779,7 @@ function renderTableComparison(state, groups) {
547
779
  <button
548
780
  class="pr-button pr-button--quiet"
549
781
  type="button"
550
- @click=${() => actions.revealGroup(group.id)}
782
+ @click=${(event) => actions.revealGroup(group.id, event.currentTarget, event.detail === 0)}
551
783
  >
552
784
  ${t(viewModel, 'pricing.showRemaining', {
553
785
  count: group.rows.length - rows.length,
@@ -566,32 +798,67 @@ function renderTableComparison(state, groups) {
566
798
  export function renderComparison(state) {
567
799
  const { viewModel } = state;
568
800
  const groups = filterGroups(state);
569
- const totalRows = groups.reduce((total, group) => total + group.rows.length, 0);
570
801
  if (viewModel.comparisonGroups.length === 0)
571
802
  return nothing;
803
+ const titleId = instanceId(state, 'comparison-title');
572
804
  return html `
573
- <section
574
- class="pr-section pr-comparison"
575
- aria-labelledby="pr-comparison-title"
576
- data-pr-part="comparison"
577
- >
805
+ <section class="pr-section pr-comparison" aria-labelledby=${titleId} data-pr-part="comparison">
578
806
  <div class="pr-section__heading">
579
807
  <div>
580
808
  <p class="pr-section__eyebrow">${t(viewModel, 'pricing.details')}</p>
581
- <h3 id="pr-comparison-title">${t(viewModel, 'pricing.comparison')}</h3>
809
+ <h3 id=${titleId}>${t(viewModel, 'pricing.comparison')}</h3>
582
810
  </div>
583
811
  </div>
584
- ${renderComparisonToolbar(state, totalRows)}
585
- ${state.compact
586
- ? renderCompactComparison(state, groups)
587
- : renderTableComparison(state, groups)}
812
+ ${viewModel.plans.length === 0
813
+ ? html `<p class="pr-empty">${t(viewModel, 'pricing.noComparison')}</p>`
814
+ : html `${renderComparisonToolbar(state, groups)}
815
+ ${groups.length === 0
816
+ ? html `<p class="pr-empty" role="status" data-pr-part="comparison-empty">
817
+ ${t(viewModel, 'pricing.noMatches')}
818
+ </p>`
819
+ : state.compact
820
+ ? renderCompactComparison(state, groups)
821
+ : renderTableComparison(state, groups)}`}
588
822
  </section>
589
823
  `;
590
824
  }
591
825
  function addOnAvailability(addOn, planId) {
592
826
  return (addOn.availableFor.length === 0 || (planId !== undefined && addOn.availableFor.includes(planId)));
593
827
  }
594
- function renderAddOnEffects(viewModel, addOn) {
828
+ function hasAddOnNotes(addOn) {
829
+ return addOn.availableFor.length > 0 || addOn.dependsOn.length > 0 || addOn.excludes.length > 0;
830
+ }
831
+ // The availability, dependency and exclusion paragraphs of an add-on card. The quantity relation
832
+ // is not here: it governs what the reader can pick, so it belongs on the face of the card, which
833
+ // renderAddOnCard draws, not behind the disclosure this prose sits in when the grid is dense.
834
+ function renderAddOnNotes(viewModel, addOn) {
835
+ return html `
836
+ ${addOn.availableFor.length
837
+ ? html `<p>
838
+ ${t(viewModel, 'pricing.availableFor', {
839
+ plans: addOn.availableFor
840
+ .map((id) => viewModel.pricing.plans.find((plan) => plan.id === id)?.name ?? id)
841
+ .join(', '),
842
+ })}
843
+ </p>`
844
+ : nothing}
845
+ ${addOn.dependsOn.length
846
+ ? html `<p>
847
+ ${t(viewModel, 'pricing.dependsOn', {
848
+ items: addOnNames(viewModel, addOn.dependsOn),
849
+ })}
850
+ </p>`
851
+ : nothing}
852
+ ${addOn.excludes.length
853
+ ? html `<p>
854
+ ${t(viewModel, 'pricing.excludes', {
855
+ items: addOnNames(viewModel, addOn.excludes),
856
+ })}
857
+ </p>`
858
+ : nothing}
859
+ `;
860
+ }
861
+ function renderAddOnEffects(viewModel, addOn, dense = false) {
595
862
  const effects = [
596
863
  ...Object.entries(addOn.features).map(([id, value]) => ({
597
864
  kind: t(viewModel, 'pricing.featureEffect'),
@@ -609,6 +876,27 @@ function renderAddOnEffects(viewModel, addOn) {
609
876
  value: unwrapValue(value),
610
877
  })),
611
878
  ];
879
+ if (dense) {
880
+ // the paragraphs join the effects behind one disclosure
881
+ if (effects.length === 0 && !hasAddOnNotes(addOn))
882
+ return nothing;
883
+ return html `
884
+ <details class="pr-addon-effects">
885
+ <summary>${t(viewModel, 'pricing.moreDetails')}</summary>
886
+ ${hasAddOnNotes(addOn)
887
+ ? html `<div class="pr-addon-notes">${renderAddOnNotes(viewModel, addOn)}</div>`
888
+ : nothing}
889
+ ${effects.length
890
+ ? html `<dl>
891
+ ${effects.map((effect) => html `<div>
892
+ <dt>${effect.kind}: ${humanizeIdentifier(effect.id)}</dt>
893
+ <dd>${renderValue(viewModel, effect.value)}</dd>
894
+ </div>`)}
895
+ </dl>`
896
+ : nothing}
897
+ </details>
898
+ `;
899
+ }
612
900
  if (effects.length === 0)
613
901
  return nothing;
614
902
  return html `
@@ -623,157 +911,369 @@ function renderAddOnEffects(viewModel, addOn) {
623
911
  </details>
624
912
  `;
625
913
  }
626
- export function renderAddOns(state) {
914
+ function renderAddOnCard(state, addOn, dense) {
627
915
  const { viewModel, selection, selectionEnabled, actions } = state;
628
- if (viewModel.addOns.length === 0)
916
+ const selected = selection.addOns[addOn.id]?.selected ?? false;
917
+ const addOnSelection = selection.addOns[addOn.id];
918
+ const quantity = addOnSelection
919
+ ? normalizeAddOnQuantity(viewModel.pricing, addOn.id, {
920
+ ...addOnSelection,
921
+ selected: true,
922
+ })
923
+ : (addOn.subscriptionConstraints?.minQuantity ?? 1);
924
+ const availableForPlan = addOnAvailability(addOn, selection.planId);
925
+ // A selected add-on is never blocked: it has to stay operable, or there would be no way to
926
+ // remove the very thing that is blocking the others.
927
+ const blockedBy = selected ? [] : blockingAddOnIds(viewModel.pricing, selection, addOn.id);
928
+ const available = availableForPlan && blockedBy.length === 0;
929
+ const constraints = addOn.subscriptionConstraints;
930
+ const current = state.currentAddOnIds?.has(addOn.id) ?? false;
931
+ // data-available is the negation of is-unavailable
932
+ const helpId = instanceId(state, 'addon-help', addOn.id);
933
+ return html `
934
+ <article
935
+ class="pr-addon-card ${selectionEnabled && available ? 'pr-selectable-card' : ''} ${selected ? 'is-selected' : ''} ${available ? '' : 'is-unavailable'}${dense ? ' pr-addon-card--dense' : ''}"
936
+ data-pr-part="add-on-card"
937
+ data-add-on-id=${addOn.id}
938
+ data-selected=${String(selected)}
939
+ data-current=${current ? 'true' : nothing}
940
+ data-available=${String(available)}
941
+ @click=${(event) => selectionEnabled && available
942
+ ? selectFromCard(event, () => actions.toggleAddOn(addOn.id, !selected))
943
+ : undefined}
944
+ >
945
+ <div class="pr-addon-card__heading">
946
+ <div>
947
+ ${current
948
+ ? html `<span class="pr-badge" data-pr-part="current-add-on-badge" data-tone="success"
949
+ >${t(viewModel, 'pricing.currentAddOn')}</span
950
+ >`
951
+ : nothing}
952
+ <h4>${addOn.name}</h4>
953
+ ${addOn.description ? html `<p>${addOn.description}</p>` : nothing}
954
+ </div>
955
+ ${selectionEnabled
956
+ ? html `<label
957
+ class="pr-selection-control pr-addon-card__check"
958
+ data-pr-part="selection-control"
959
+ >
960
+ <input
961
+ type="checkbox"
962
+ .checked=${selected}
963
+ ?disabled=${!available}
964
+ aria-label=${t(viewModel, selected ? 'pricing.removeAddOn' : 'pricing.addAddOn', {
965
+ addOn: addOn.name,
966
+ })}
967
+ aria-describedby="${helpId}"
968
+ @change=${(event) => actions.toggleAddOn(addOn.id, event.currentTarget.checked)}
969
+ />
970
+ <span class="pr-selection-control__icon" aria-hidden="true"></span>
971
+ <span class="pr-selection-control__label" aria-hidden="true"
972
+ >${selected ? t(viewModel, 'pricing.added') : t(viewModel, 'pricing.add')}</span
973
+ >
974
+ </label>`
975
+ : nothing}
976
+ </div>
977
+ <div class="pr-price pr-price--addon">
978
+ ${renderPrice(viewModel, viewModel.resolved.addOnPrices[addOn.id], addOn.unit)}
979
+ </div>
980
+ <div id="${helpId}" class="pr-addon-card__details">
981
+ ${!availableForPlan
982
+ ? html `<p class="pr-status" data-pr-part="add-on-status" data-reason="plan">
983
+ ${t(viewModel, 'pricing.notAvailable')}
984
+ </p>`
985
+ : blockedBy.length > 0
986
+ ? // the add-ons that block it by name: the reader needs to know what to drop, not
987
+ // the whole list of what this one excludes
988
+ html `<p class="pr-status" data-pr-part="add-on-status" data-reason="excluded">
989
+ ${t(viewModel, 'pricing.notAvailableWith', {
990
+ items: addOnNames(viewModel, blockedBy),
991
+ })}
992
+ </p>`
993
+ : nothing}
994
+ ${renderQuantityConstraints(viewModel, constraints)}
995
+ ${dense ? nothing : renderAddOnNotes(viewModel, addOn)}
996
+ </div>
997
+ ${renderAddOnEffects(viewModel, addOn, dense)}
998
+ ${selectionEnabled && selected && constraints
999
+ ? html `<div class="pr-stepper" data-pr-part="add-on-quantity">
1000
+ <span>${t(viewModel, 'pricing.addOnQuantity', { addOn: addOn.name })}</span>
1001
+ <div>
1002
+ <button
1003
+ type="button"
1004
+ aria-label=${t(viewModel, 'pricing.decrease', { item: addOn.name })}
1005
+ @click=${() => actions.updateAddOnQuantity(addOn.id, quantity - constraints.quantityStep)}
1006
+ >
1007
+ −
1008
+ </button>
1009
+ <input
1010
+ type="number"
1011
+ aria-label=${t(viewModel, 'pricing.addOnQuantity', { addOn: addOn.name })}
1012
+ .value=${String(quantity)}
1013
+ min=${constraints.minQuantity}
1014
+ max=${maxQuantityOnGrid(constraints) ?? nothing}
1015
+ step=${constraints.quantityStep}
1016
+ @change=${(event) => actions.updateAddOnQuantity(addOn.id, Number(event.currentTarget.value))}
1017
+ />
1018
+ <button
1019
+ type="button"
1020
+ aria-label=${t(viewModel, 'pricing.increase', { item: addOn.name })}
1021
+ @click=${() => actions.updateAddOnQuantity(addOn.id, quantity + constraints.quantityStep)}
1022
+ >
1023
+ +
1024
+ </button>
1025
+ </div>
1026
+ </div>`
1027
+ : nothing}
1028
+ </article>
1029
+ `;
1030
+ }
1031
+ function renderAddOnGrid(state, addOns, dense, keyed = false) {
1032
+ // The visible cards of the filter mode change with the search and show-more, so they are keyed
1033
+ // by id: an open disclosure or the focus stays with its add-on.
1034
+ return html `<div class="pr-addon-grid${dense ? ' pr-addon-grid--dense' : ''}">
1035
+ ${keyed
1036
+ ? repeat(addOns, (addOn) => addOn.id, (addOn) => renderAddOnCard(state, addOn, dense))
1037
+ : addOns.map((addOn) => renderAddOnCard(state, addOn, dense))}
1038
+ </div>`;
1039
+ }
1040
+ function renderFilteredAddOns(state, dense) {
1041
+ const { viewModel, selection, actions } = state;
1042
+ const all = viewModel.addOns;
1043
+ const search = (state.addOnSearch ?? '').trim().toLowerCase();
1044
+ const isSelected = (addOn) => selection.addOns[addOn.id]?.selected ?? false;
1045
+ let visible;
1046
+ if (search) {
1047
+ visible = all.filter((addOn) => isSelected(addOn) ||
1048
+ addOn.name.toLowerCase().includes(search) ||
1049
+ (addOn.description ?? '').toLowerCase().includes(search));
1050
+ }
1051
+ else if (state.addOnsRevealed) {
1052
+ visible = [...all];
1053
+ }
1054
+ else {
1055
+ visible = all.filter((addOn, index) => index < ADDON_VISIBLE_CAP || isSelected(addOn));
1056
+ }
1057
+ const hidden = all.length - visible.length;
1058
+ const label = t(viewModel, 'pricing.searchAddOns');
1059
+ const counter = t(viewModel, 'pricing.rowsShownOf', { shown: visible.length, total: all.length });
1060
+ return html `<div class="pr-addon-main">
1061
+ <div class="pr-addon-toolbar" data-pr-part="addon-toolbar">
1062
+ <label class="pr-search">
1063
+ <span class="pr-sr-only">${label}</span>
1064
+ <input
1065
+ type="search"
1066
+ placeholder=${label}
1067
+ .value=${state.addOnSearch ?? ''}
1068
+ @input=${(event) => actions.updateAddOnSearch?.(event.currentTarget.value)}
1069
+ />
1070
+ </label>
1071
+ <span aria-live="polite">${counter}</span>
1072
+ </div>
1073
+ ${visible.length === 0
1074
+ ? html `<p class="pr-empty" role="status" data-pr-part="addon-empty">
1075
+ ${t(viewModel, 'pricing.noMatches')}
1076
+ </p>`
1077
+ : renderAddOnGrid(state, visible, dense, true)}
1078
+ ${!search && hidden > 0
1079
+ ? html `<button
1080
+ class="pr-button pr-button--quiet"
1081
+ type="button"
1082
+ @click=${(event) => actions.revealAddOns?.(event?.detail === 0)}
1083
+ >
1084
+ ${t(viewModel, 'pricing.showRemaining', { count: hidden })}
1085
+ </button>`
1086
+ : nothing}
1087
+ </div>`;
1088
+ }
1089
+ // Only the parts that actually constrain. A minimum of one and a step of one say nothing, and
1090
+ // a line reading "Quantity 1-100000000, in steps of 1" is noise around a single useful number.
1091
+ // A missing maximum is already unbounded: normalizePricing drops a YAML `.inf` as non-finite.
1092
+ function renderQuantityConstraints(viewModel, constraints) {
1093
+ if (!constraints)
1094
+ return nothing;
1095
+ const { minQuantity, maxQuantity, quantityStep } = constraints;
1096
+ const plain = minQuantity <= 1 && quantityStep <= 1;
1097
+ if (plain && maxQuantity === undefined)
629
1098
  return nothing;
1099
+ const num = (value) => new Intl.NumberFormat(viewModel.locale).format(value);
1100
+ const parts = [];
1101
+ if (plain) {
1102
+ parts.push(t(viewModel, 'pricing.quantityUpTo', { max: num(maxQuantity) }));
1103
+ }
1104
+ else {
1105
+ parts.push(t(viewModel, 'pricing.quantityConstraints', {
1106
+ min: num(minQuantity),
1107
+ max: maxQuantity === undefined ? t(viewModel, 'pricing.unbounded') : num(maxQuantity),
1108
+ }));
1109
+ }
1110
+ if (quantityStep > 1) {
1111
+ parts.push(t(viewModel, 'pricing.quantityInSteps', { step: num(quantityStep) }));
1112
+ }
1113
+ return html `<p>${parts.join(', ')}</p>`;
1114
+ }
1115
+ export function renderAddOns(state) {
1116
+ const { viewModel } = state;
1117
+ if (viewModel.addOns.length === 0 || state.hiddenSections?.has('addOns'))
1118
+ return nothing;
1119
+ const dense = viewModel.addOns.length > ADDON_DENSE_THRESHOLD;
1120
+ const filtered = viewModel.addOns.length > addOnFilterThresholdOf(state.addOnFilterThreshold);
1121
+ const titleId = instanceId(state, 'addons-title');
630
1122
  return html `
631
- <section class="pr-section" aria-labelledby="pr-addons-title" data-pr-part="add-ons">
1123
+ <section class="pr-section" aria-labelledby=${titleId} data-pr-part="add-ons">
632
1124
  <div class="pr-section__heading">
633
1125
  <div>
634
1126
  <p class="pr-section__eyebrow">${t(viewModel, 'pricing.addOns')}</p>
635
- <h3 id="pr-addons-title">${t(viewModel, 'pricing.addOns')}</h3>
1127
+ <h3 id=${titleId}>${t(viewModel, 'pricing.addOnsTitle')}</h3>
636
1128
  </div>
637
1129
  </div>
638
- <div class="pr-addon-layout">
639
- <div class="pr-addon-grid">
640
- ${viewModel.addOns.map((addOn) => {
641
- const selected = selection.addOns[addOn.id]?.selected ?? false;
642
- const quantity = selection.addOns[addOn.id]?.quantity ??
643
- addOn.subscriptionConstraints?.minQuantity ??
644
- 1;
645
- const available = addOnAvailability(addOn, selection.planId);
646
- const constraints = addOn.subscriptionConstraints;
647
- return html `
648
- <article
649
- class="pr-addon-card ${selectionEnabled && available ? 'pr-selectable-card' : ''} ${selected ? 'is-selected' : ''} ${available ? '' : 'is-unavailable'}"
650
- data-pr-part="add-on-card"
651
- data-add-on-id=${addOn.id}
652
- data-selected=${String(selected)}
653
- @click=${(event) => selectionEnabled && available
654
- ? selectFromCard(event, () => actions.toggleAddOn(addOn.id, !selected))
655
- : undefined}
656
- >
657
- <div class="pr-addon-card__heading">
658
- <div>
659
- <h4>${addOn.name}</h4>
660
- ${addOn.description ? html `<p>${addOn.description}</p>` : nothing}
661
- </div>
662
- ${selectionEnabled
663
- ? html `<label
664
- class="pr-selection-control pr-addon-card__check"
665
- data-pr-part="selection-control"
666
- >
667
- <input
668
- type="checkbox"
669
- .checked=${selected}
670
- ?disabled=${!available}
671
- aria-label=${t(viewModel, selected ? 'pricing.removeAddOn' : 'pricing.addAddOn', { addOn: addOn.name })}
672
- aria-describedby="pr-addon-help-${addOn.id}"
673
- @change=${(event) => actions.toggleAddOn(addOn.id, event.currentTarget.checked)}
674
- />
675
- <span class="pr-selection-control__icon" aria-hidden="true"></span>
676
- <span class="pr-selection-control__label" aria-hidden="true"
677
- >${selected ? t(viewModel, 'pricing.added') : t(viewModel, 'pricing.add')}</span
678
- >
679
- </label>`
680
- : nothing}
681
- </div>
682
- <div class="pr-price pr-price--addon">
683
- ${renderPrice(viewModel, viewModel.resolved.addOnPrices[addOn.id], addOn.unit)}
684
- </div>
685
- <div id="pr-addon-help-${addOn.id}" class="pr-addon-card__details">
686
- ${!available
687
- ? html `<p class="pr-status">${t(viewModel, 'pricing.notAvailable')}</p>`
688
- : nothing}
689
- ${addOn.availableFor.length
690
- ? html `<p>
691
- ${t(viewModel, 'pricing.availableFor', {
692
- plans: addOn.availableFor
693
- .map((id) => viewModel.pricing.plans.find((plan) => plan.id === id)?.name ??
694
- id)
695
- .join(', '),
696
- })}
697
- </p>`
698
- : nothing}
699
- ${addOn.dependsOn.length
700
- ? html `<p>
701
- ${t(viewModel, 'pricing.dependsOn', {
702
- items: addOn.dependsOn.join(', '),
703
- })}
704
- </p>`
705
- : nothing}
706
- ${addOn.excludes.length
707
- ? html `<p>
708
- ${t(viewModel, 'pricing.excludes', {
709
- items: addOn.excludes.join(', '),
710
- })}
711
- </p>`
712
- : nothing}
713
- ${constraints
714
- ? html `<p>
715
- ${t(viewModel, 'pricing.quantityConstraints', {
716
- min: constraints.minQuantity,
717
- max: constraints.maxQuantity ?? t(viewModel, 'pricing.unbounded'),
718
- step: constraints.quantityStep,
719
- })}
720
- </p>`
721
- : nothing}
722
- </div>
723
- ${renderAddOnEffects(viewModel, addOn)}
724
- ${selectionEnabled && selected && constraints
725
- ? html `<div class="pr-stepper" data-pr-part="add-on-quantity">
726
- <span>${t(viewModel, 'pricing.addOnQuantity', { addOn: addOn.name })}</span>
727
- <div>
728
- <button
729
- type="button"
730
- aria-label=${t(viewModel, 'pricing.decrease', { item: addOn.name })}
731
- @click=${() => actions.updateAddOnQuantity(addOn.id, quantity - constraints.quantityStep)}
732
- >
733
- −
734
- </button>
735
- <input
736
- type="number"
737
- aria-label=${t(viewModel, 'pricing.addOnQuantity', {
738
- addOn: addOn.name,
739
- })}
740
- .value=${String(quantity)}
741
- min=${constraints.minQuantity}
742
- max=${constraints.maxQuantity ?? nothing}
743
- step=${constraints.quantityStep}
744
- @change=${(event) => actions.updateAddOnQuantity(addOn.id, Number(event.currentTarget.value))}
745
- />
746
- <button
747
- type="button"
748
- aria-label=${t(viewModel, 'pricing.increase', { item: addOn.name })}
749
- @click=${() => actions.updateAddOnQuantity(addOn.id, quantity + constraints.quantityStep)}
750
- >
751
- +
752
- </button>
753
- </div>
754
- </div>`
755
- : nothing}
756
- </article>
757
- `;
758
- })}
759
- </div>
760
- ${renderSummary(state)}
1130
+ <div
1131
+ class="pr-addon-layout"
1132
+ data-pr-summary=${state.summaryOutsideAddOns ? 'outside' : nothing}
1133
+ >
1134
+ ${filtered
1135
+ ? renderFilteredAddOns(state, dense)
1136
+ : renderAddOnGrid(state, viewModel.addOns, dense)}
1137
+ ${state.summaryOutsideAddOns || state.hiddenSections?.has('summary')
1138
+ ? nothing
1139
+ : renderSummary(state)}
761
1140
  </div>
762
1141
  </section>
763
1142
  `;
764
1143
  }
1144
+ // The formula of a priced line, or the label or the quote note of a line that needs a quote.
1145
+ function breakdownDetail(viewModel, line) {
1146
+ const { currency } = viewModel.resolved.breakdown;
1147
+ if (line.requiresQuote || line.unitAmount === undefined || line.amount === undefined) {
1148
+ return line.label ?? t(viewModel, 'pricing.requiresQuote');
1149
+ }
1150
+ const numbers = new Intl.NumberFormat(viewModel.locale);
1151
+ return t(viewModel, 'pricing.breakdownFormula', {
1152
+ unitPrice: formatCurrency(line.unitAmount, currency, viewModel.locale),
1153
+ quantity: numbers.format(line.quantity),
1154
+ multiplier: numbers.format(line.billingMultiplier),
1155
+ amount: formatCurrency(line.amount, currency, viewModel.locale),
1156
+ });
1157
+ }
1158
+ function renderBreakdownLine(viewModel, line) {
1159
+ return html `<li
1160
+ class="pr-breakdown__line"
1161
+ data-pr-part="breakdown-line"
1162
+ data-line-kind=${line.kind}
1163
+ data-line-id=${line.id}
1164
+ >
1165
+ <div class="pr-breakdown__name">${line.name}</div>
1166
+ <div class="pr-breakdown__detail">${breakdownDetail(viewModel, line)}</div>
1167
+ </li>`;
1168
+ }
1169
+ // The panel is rendered from the element's open state whatever the selection, as a labelled group
1170
+ // of a list and a definition list. The closed panel is a template without bindings in its content, so it has no child nodes.
1171
+ function renderBreakdownPanel(state) {
1172
+ const { viewModel, breakdownOpen, breakdownId } = state;
1173
+ if (!breakdownOpen) {
1174
+ return html `<div id=${breakdownId} data-pr-part="breakdown" hidden></div>`;
1175
+ }
1176
+ const { breakdown } = viewModel.resolved;
1177
+ const lines = [...(breakdown.plan ? [breakdown.plan] : []), ...breakdown.addOns];
1178
+ const period = viewModel.pricing.billing.length > 1 ? breakdown.billingPeriod : undefined;
1179
+ const periodLabel = period
1180
+ ? (viewModel.presentation.billingLabels?.[period.id] ?? period.label)
1181
+ : undefined;
1182
+ return html `
1183
+ <div
1184
+ class="pr-breakdown"
1185
+ id=${breakdownId}
1186
+ data-pr-part="breakdown"
1187
+ role="group"
1188
+ aria-labelledby="${breakdownId}-title"
1189
+ >
1190
+ <p class="pr-breakdown__title" id="${breakdownId}-title">
1191
+ ${t(viewModel, 'pricing.breakdownTitle')}
1192
+ </p>
1193
+ <ul class="pr-breakdown__lines">
1194
+ ${lines.map((line) => renderBreakdownLine(viewModel, line))}
1195
+ </ul>
1196
+ <dl class="pr-breakdown__totals">
1197
+ ${periodLabel !== undefined
1198
+ ? html `<div>
1199
+ <dt>${t(viewModel, 'pricing.billing')}</dt>
1200
+ <dd>${periodLabel}</dd>
1201
+ </div>`
1202
+ : nothing}
1203
+ <div>
1204
+ <dt>${t(viewModel, 'pricing.currency')}</dt>
1205
+ <dd>${breakdown.currency}</dd>
1206
+ </div>
1207
+ <div class="pr-breakdown__total">
1208
+ <dt>
1209
+ ${breakdown.requiresQuote ? t(viewModel, 'pricing.subtotal') : t(viewModel, 'pricing.total')}
1210
+ </dt>
1211
+ <dd>${formatCurrency(breakdown.subtotal, breakdown.currency, viewModel.locale)}</dd>
1212
+ </div>
1213
+ </dl>
1214
+ ${breakdown.requiresQuote
1215
+ ? html `<p class="pr-breakdown__quote">${t(viewModel, 'pricing.requiresQuote')}</p>`
1216
+ : nothing}
1217
+ </div>
1218
+ `;
1219
+ }
1220
+ // Rendered outside the live region, so a change of label is not announced by the summary.
1221
+ function renderSummaryAction(state) {
1222
+ const { viewModel, summaryAction, pendingActionId, selection, actions } = state;
1223
+ // The slot node stands in for the summary action with or without a current plan; data-pending
1224
+ // follows aria-disabled of the built-in action (any pending action), not its own-id state.
1225
+ if (state.ctaEnabled && state.slots?.has('summary-actions')) {
1226
+ return html `<div
1227
+ data-pr-slot="summary-actions"
1228
+ data-pr-part="summary-actions-slot"
1229
+ data-relation=${summaryAction?.relation ?? nothing}
1230
+ data-pending=${pendingActionId !== undefined ? 'true' : nothing}
1231
+ ></div>`;
1232
+ }
1233
+ if (!state.ctaEnabled || !summaryAction)
1234
+ return nothing;
1235
+ const pending = isPendingActionId(pendingActionId, summaryAction.actionId);
1236
+ const origin = { relation: summaryAction.relation, source: 'summary' };
1237
+ const trigger = () => actions.triggerAction(summaryAction.actionId, selection.planId, undefined, undefined, origin);
1238
+ return html `<button
1239
+ class="pr-button pr-button--primary"
1240
+ data-pr-part="summary-action"
1241
+ data-relation=${summaryAction.relation}
1242
+ data-pending=${pending ? 'true' : nothing}
1243
+ aria-busy=${pending ? 'true' : nothing}
1244
+ aria-disabled=${pendingActionId !== undefined ? 'true' : nothing}
1245
+ type="button"
1246
+ ?disabled=${summaryAction.disabled}
1247
+ @click=${trigger}
1248
+ >
1249
+ ${renderCtaLabel(viewModel, summaryAction.label, pending)}
1250
+ </button>`;
1251
+ }
765
1252
  export function renderSummary(state) {
766
- const { viewModel } = state;
1253
+ const { viewModel, breakdownOpen, breakdownId, actions } = state;
767
1254
  const subtotal = formatCurrency(viewModel.resolved.subtotal, viewModel.pricing.metadata.currency, viewModel.locale);
768
1255
  return html `
769
- <aside class="pr-summary" data-pr-part="summary" aria-live="polite">
770
- <p>
771
- ${viewModel.resolved.requiresQuote ? t(viewModel, 'pricing.subtotal') : t(viewModel, 'pricing.total')}
772
- </p>
773
- <strong>${subtotal}</strong>
774
- ${viewModel.resolved.requiresQuote
1256
+ <aside class="pr-summary" data-pr-part="summary">
1257
+ <div class="pr-summary__live" aria-live="polite">
1258
+ <p>
1259
+ ${viewModel.resolved.requiresQuote ? t(viewModel, 'pricing.subtotal') : t(viewModel, 'pricing.total')}
1260
+ </p>
1261
+ <strong>${subtotal}</strong>
1262
+ ${viewModel.resolved.requiresQuote
775
1263
  ? html `<span>${t(viewModel, 'pricing.requiresQuote')}</span>`
776
1264
  : nothing}
1265
+ </div>
1266
+ ${renderSummaryAction(state)}
1267
+ <button
1268
+ class="pr-breakdown-toggle"
1269
+ data-pr-part="breakdown-toggle"
1270
+ type="button"
1271
+ aria-label=${t(viewModel, 'pricing.breakdownToggle')}
1272
+ aria-expanded=${String(breakdownOpen)}
1273
+ aria-controls=${breakdownId}
1274
+ @click=${actions.toggleBreakdown}
1275
+ ></button>
1276
+ ${renderBreakdownPanel(state)}
777
1277
  </aside>
778
1278
  `;
779
1279
  }
@@ -782,9 +1282,10 @@ export function renderCatalogMetadata(state) {
782
1282
  if (viewModel.mode !== 'catalog')
783
1283
  return nothing;
784
1284
  const metadata = viewModel.pricing.metadata;
1285
+ const titleId = instanceId(state, 'metadata-title');
785
1286
  return html `
786
- <section class="pr-metadata" aria-labelledby="pr-metadata-title" data-pr-part="metadata">
787
- <h3 id="pr-metadata-title">${t(viewModel, 'pricing.details')}</h3>
1287
+ <section class="pr-metadata" aria-labelledby=${titleId} data-pr-part="metadata">
1288
+ <h3 id=${titleId}>${t(viewModel, 'pricing.details')}</h3>
788
1289
  <dl>
789
1290
  <div>
790
1291
  <dt>${t(viewModel, 'pricing.version')}</dt>
@@ -814,32 +1315,63 @@ export function renderCatalogMetadata(state) {
814
1315
  </section>
815
1316
  `;
816
1317
  }
1318
+ // the shell keeps its labelled heading; the slot node replaces the skeleton
1319
+ export function renderLoadingSlotShell(titleId, label) {
1320
+ return html `
1321
+ <section class="pr-shell pr-shell--loading" aria-labelledby=${titleId}>
1322
+ <h2 id=${titleId} class="pr-sr-only">${label}</h2>
1323
+ <div data-pr-slot="loading" data-pr-part="loading-slot"></div>
1324
+ </section>
1325
+ `;
1326
+ }
1327
+ // The slot node is the only child; the diagnostics travel as JSON in data-diagnostics.
1328
+ export function renderErrorSlotShell(diagnostics) {
1329
+ const entries = diagnostics.map((item) => ({
1330
+ code: item.code,
1331
+ severity: item.severity,
1332
+ message: item.message,
1333
+ ...(item.path !== undefined ? { path: item.path } : {}),
1334
+ }));
1335
+ return html `<section class="pr-shell pr-error" role="alert">
1336
+ <div
1337
+ data-pr-slot="error"
1338
+ data-pr-part="error-slot"
1339
+ data-diagnostics=${JSON.stringify(entries)}
1340
+ ></div>
1341
+ </section>`;
1342
+ }
817
1343
  export function renderPendingDialog(state) {
818
1344
  const pending = state.pendingAddOnChange;
819
1345
  if (!pending)
820
1346
  return nothing;
821
1347
  const { viewModel, actions } = state;
1348
+ const titleId = instanceId(state, 'dialog-title');
1349
+ const descriptionId = instanceId(state, 'dialog-description');
822
1350
  return html `
823
1351
  <div class="pr-dialog-backdrop">
824
1352
  <div
825
1353
  class="pr-dialog"
826
1354
  role="dialog"
827
1355
  aria-modal="true"
828
- aria-labelledby="pr-dialog-title"
829
- aria-describedby="pr-dialog-description"
1356
+ aria-labelledby=${titleId}
1357
+ aria-describedby=${descriptionId}
830
1358
  @keydown=${(event) => {
831
1359
  if (event.key === 'Escape')
832
1360
  actions.cancelAddOnChange();
833
1361
  }}
834
1362
  >
835
- <h3 id="pr-dialog-title">${t(viewModel, 'pricing.confirmChanges')}</h3>
836
- <p id="pr-dialog-description">${t(viewModel, 'pricing.confirmChangesDescription')}</p>
1363
+ <h3 id=${titleId}>${t(viewModel, 'pricing.confirmChanges')}</h3>
1364
+ <p id=${descriptionId}>${t(viewModel, 'pricing.confirmChangesDescription')}</p>
837
1365
  ${pending.addIds.length
838
- ? html `<p>${t(viewModel, 'pricing.willAdd', { items: pending.addIds.join(', ') })}</p>`
1366
+ ? html `<p>
1367
+ ${t(viewModel, 'pricing.willAdd', { items: addOnNames(viewModel, pending.addIds) })}
1368
+ </p>`
839
1369
  : nothing}
840
1370
  ${pending.removeIds.length
841
1371
  ? html `<p>
842
- ${t(viewModel, 'pricing.willRemove', { items: pending.removeIds.join(', ') })}
1372
+ ${t(viewModel, 'pricing.willRemove', {
1373
+ items: addOnNames(viewModel, pending.removeIds),
1374
+ })}
843
1375
  </p>`
844
1376
  : nothing}
845
1377
  <div class="pr-dialog__actions">