@akinon/pz-theme 2.0.37-rc.0 → 2.0.38-beta.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 (78) hide show
  1. package/CHANGELOG.md +15 -31
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
@@ -0,0 +1,667 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useRef, useState } from 'react';
4
+ import { getResponsiveValue, partAttrs } from '../utils';
5
+ import { BlockRendererProps } from './block-renderer-registry';
6
+ import {
7
+ DesignerPlaceholder,
8
+ getProductName,
9
+ pickProductField,
10
+ readToggle,
11
+ useThemeProduct
12
+ } from './product-block-utils';
13
+
14
+ type ShareNetwork =
15
+ | 'whatsapp'
16
+ | 'x'
17
+ | 'facebook'
18
+ | 'pinterest'
19
+ | 'linkedin'
20
+ | 'telegram'
21
+ | 'email'
22
+ | 'copy';
23
+
24
+ /**
25
+ * Every network the block can draw, in the order both reference designs put
26
+ * them. The toggle key is what the editor writes, so the pair lives here once
27
+ * instead of being repeated in the reader and the renderer.
28
+ */
29
+ const NETWORKS: Array<{
30
+ id: ShareNetwork;
31
+ toggleKey: string;
32
+ enabledByDefault: boolean;
33
+ label: string;
34
+ }> = [
35
+ {
36
+ id: 'whatsapp',
37
+ toggleKey: 'showWhatsapp',
38
+ enabledByDefault: true,
39
+ label: 'WhatsApp'
40
+ },
41
+ { id: 'x', toggleKey: 'showX', enabledByDefault: true, label: 'X' },
42
+ {
43
+ id: 'facebook',
44
+ toggleKey: 'showFacebook',
45
+ enabledByDefault: true,
46
+ label: 'Facebook'
47
+ },
48
+ {
49
+ id: 'pinterest',
50
+ toggleKey: 'showPinterest',
51
+ enabledByDefault: true,
52
+ label: 'Pinterest'
53
+ },
54
+ {
55
+ id: 'linkedin',
56
+ toggleKey: 'showLinkedin',
57
+ enabledByDefault: false,
58
+ label: 'LinkedIn'
59
+ },
60
+ {
61
+ id: 'telegram',
62
+ toggleKey: 'showTelegram',
63
+ enabledByDefault: false,
64
+ label: 'Telegram'
65
+ },
66
+ { id: 'email', toggleKey: 'showEmail', enabledByDefault: true, label: 'Email' },
67
+ { id: 'copy', toggleKey: 'showCopy', enabledByDefault: true, label: '' }
68
+ ];
69
+
70
+ /**
71
+ * Stroke glyphs on the same 24x24 grid the other chrome blocks draw on, so a
72
+ * single `stroke: currentColor` inherits whatever colour the row sits in.
73
+ */
74
+ const GLYPHS: Record<ShareNetwork | 'share', React.ReactNode> = {
75
+ whatsapp: (
76
+ <>
77
+ <path d="M3.5 20.5l1.4-4.2A8.2 8.2 0 1 1 8 19.4z" />
78
+ <path d="M9 9.5c0 3 2.5 5.5 5.5 5.5l1-1.6-2-1-1 .9a5 5 0 0 1-1.8-1.8l.9-1-1-2z" />
79
+ </>
80
+ ),
81
+ x: <path d="M4 4l16 16M20 4L4 20" />,
82
+ facebook: (
83
+ <path d="M14 8.5V7a1.5 1.5 0 0 1 1.5-1.5H17V3h-2A4 4 0 0 0 11 7v1.5H9V11h2v10h3V11h2.2l.4-2.5H14z" />
84
+ ),
85
+ pinterest: (
86
+ <>
87
+ <circle cx="12" cy="12" r="9" />
88
+ <path d="M9.5 21l2.2-8.5" />
89
+ <path d="M9 11a3 3 0 1 1 5 2.2" />
90
+ </>
91
+ ),
92
+ linkedin: (
93
+ <>
94
+ <path d="M6.5 9.5V20" />
95
+ <circle cx="6.5" cy="5" r="1.1" />
96
+ <path d="M11 20v-6.5a3.5 3.5 0 0 1 7 0V20" />
97
+ </>
98
+ ),
99
+ telegram: <path d="M21 4.5L2.8 11.3l5 1.8L18 7l-7.5 7.4.3 5 2.7-3.4 4 3z" />,
100
+ email: (
101
+ <>
102
+ <rect x="3" y="5" width="18" height="14" rx="2" />
103
+ <path d="M3.5 6.5L12 13l8.5-6.5" />
104
+ </>
105
+ ),
106
+ copy: (
107
+ <>
108
+ <path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7L11.5 7" />
109
+ <path d="M14 10a4 4 0 0 0-5.7 0l-3 3A4 4 0 0 0 11 18.7l1.5-1.5" />
110
+ </>
111
+ ),
112
+ share: (
113
+ <>
114
+ <circle cx="18" cy="5" r="2.6" />
115
+ <circle cx="6" cy="12" r="2.6" />
116
+ <circle cx="18" cy="19" r="2.6" />
117
+ <path d="M8.3 10.8l7.4-4.3M8.3 13.2l7.4 4.3" />
118
+ </>
119
+ )
120
+ };
121
+
122
+ const HTTP_SCHEME = /^https?:\/\//i;
123
+
124
+ /**
125
+ * A share target receives the URL as a query parameter, so a relative product
126
+ * path would send the shopper to the network's own domain. `window` does not
127
+ * exist on the server, which is why the origin is handed in: it is the authored
128
+ * `baseUrl` until hydration and the browser's own origin afterwards. Anything
129
+ * that does not resolve to http(s) returns '' rather than being passed on —
130
+ * the value is also what "copy link" puts on the clipboard.
131
+ */
132
+ const toAbsoluteUrl = (path: string, origin: string): string => {
133
+ const trimmed = String(path || '').trim();
134
+ if (!trimmed) return '';
135
+
136
+ const isAbsolute = HTTP_SCHEME.test(trimmed);
137
+ if (!isAbsolute && !origin) return '';
138
+
139
+ try {
140
+ const resolved = isAbsolute ? new URL(trimmed) : new URL(trimmed, origin);
141
+
142
+ return resolved.protocol === 'http:' || resolved.protocol === 'https:'
143
+ ? resolved.toString()
144
+ : '';
145
+ } catch {
146
+ return '';
147
+ }
148
+ };
149
+
150
+ /**
151
+ * `network=Label` pairs, one per `;` or newline. The built-in names are English
152
+ * proper nouns; a brand running in another language has no other way to reach
153
+ * the accessible name of an icon.
154
+ */
155
+ const parseLabelOverrides = (raw: string): Record<string, string> =>
156
+ String(raw || '')
157
+ .split(/[;\n]/)
158
+ .map((entry) => entry.trim())
159
+ .filter(Boolean)
160
+ .reduce<Record<string, string>>((acc, entry) => {
161
+ const separatorIndex = entry.indexOf('=');
162
+ if (separatorIndex <= 0) return acc;
163
+
164
+ const key = entry.slice(0, separatorIndex).trim().toLowerCase();
165
+ const value = entry.slice(separatorIndex + 1).trim();
166
+ if (key && value) acc[key] = value;
167
+
168
+ return acc;
169
+ }, {});
170
+
171
+ const clampNumber = (
172
+ value: unknown,
173
+ fallback: number,
174
+ min: number,
175
+ max: number
176
+ ) => {
177
+ const numeric = Math.round(Number(value));
178
+ if (!Number.isFinite(numeric)) return fallback;
179
+
180
+ return Math.min(max, Math.max(min, numeric));
181
+ };
182
+
183
+ interface ShareTargets {
184
+ url: string;
185
+ text: string;
186
+ subject: string;
187
+ body: string;
188
+ image: string;
189
+ }
190
+
191
+ /** Every interpolated value goes through encodeURIComponent, without exception. */
192
+ const buildNetworkHref = (
193
+ network: ShareNetwork,
194
+ { url, text, subject, body, image }: ShareTargets
195
+ ): string => {
196
+ const enc = encodeURIComponent;
197
+
198
+ switch (network) {
199
+ case 'whatsapp':
200
+ return `https://wa.me/?text=${enc([text, url].filter(Boolean).join(' '))}`;
201
+ case 'x':
202
+ return `https://x.com/intent/post?url=${enc(url)}&text=${enc(text)}`;
203
+ case 'facebook':
204
+ return `https://www.facebook.com/sharer/sharer.php?u=${enc(url)}`;
205
+ case 'pinterest':
206
+ return `https://www.pinterest.com/pin/create/button/?url=${enc(
207
+ url
208
+ )}&media=${enc(image)}&description=${enc(text)}`;
209
+ case 'linkedin':
210
+ return `https://www.linkedin.com/sharing/share-offsite/?url=${enc(url)}`;
211
+ case 'telegram':
212
+ return `https://t.me/share/url?url=${enc(url)}&text=${enc(text)}`;
213
+ case 'email':
214
+ return `mailto:?subject=${enc(subject)}&body=${enc(body)}`;
215
+ default:
216
+ return '';
217
+ }
218
+ };
219
+
220
+ /**
221
+ * Functional block: sharing the ambient product — a repeated row's own product
222
+ * on a card, the page's product on a PDP — and the current page URL when there
223
+ * is no product at all, which is what makes it usable on a blog post or a
224
+ * campaign page. Two layouts cover both reference designs: an inline row of
225
+ * network icons (Nautica) and a single trigger that reveals them (GANT).
226
+ *
227
+ * `nativeShare` is the switch that keeps this inert: with it off the block
228
+ * never touches `navigator`, so the markup is decided entirely by `layout` and
229
+ * the server and the client agree. Turned on, the trigger feature-detects
230
+ * `navigator.share` AT CLICK TIME — detecting at import or render time would
231
+ * branch the markup on something the server cannot know, which hydrates as a
232
+ * mismatch — and falls back to revealing the icon row when it is absent.
233
+ *
234
+ * Properties: layout, nativeShare, showWhatsapp, showX, showFacebook,
235
+ * showPinterest, showLinkedin, showTelegram, showEmail, showCopy, triggerLabel,
236
+ * triggerAriaLabel, showTriggerIcon, panelPlacement, showLabels, labelOverrides,
237
+ * copyLabel, copyConfirmLabel, copyFailLabel, copyFallback, confirmDuration,
238
+ * shareTextTemplate, emailSubjectTemplate, emailBodyTemplate, iconSize, gap,
239
+ * openInNewTab, baseUrl, emptyLabel.
240
+ * Parts: root, trigger, list, item, icon, label, feedback, fallback-input.
241
+ */
242
+ const ShareBlock = ({
243
+ block,
244
+ currentBreakpoint = 'desktop',
245
+ isDesigner = false
246
+ }: BlockRendererProps) => {
247
+ const product = useThemeProduct();
248
+
249
+ const prop = (key: string, fallback: string) =>
250
+ String(
251
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
252
+ );
253
+
254
+ const layoutValue = prop('layout', 'inline');
255
+ const layout = layoutValue === 'trigger' ? 'trigger' : 'inline';
256
+ const nativeShare = readToggle(
257
+ block.properties?.nativeShare,
258
+ currentBreakpoint,
259
+ false
260
+ );
261
+ const triggerLabel = prop('triggerLabel', 'Share');
262
+ const triggerAriaLabel = prop('triggerAriaLabel', '');
263
+ const showTriggerIcon = readToggle(
264
+ block.properties?.showTriggerIcon,
265
+ currentBreakpoint,
266
+ true
267
+ );
268
+ const panelPlacementValue = prop('panelPlacement', 'below');
269
+ const panelPlacement = ['below', 'above', 'inline'].includes(
270
+ panelPlacementValue
271
+ )
272
+ ? panelPlacementValue
273
+ : 'below';
274
+ const showLabels = readToggle(
275
+ block.properties?.showLabels,
276
+ currentBreakpoint,
277
+ false
278
+ );
279
+ const labelOverrides = parseLabelOverrides(prop('labelOverrides', ''));
280
+ const copyLabel = prop('copyLabel', 'Copy link');
281
+ const copyConfirmLabel = prop('copyConfirmLabel', 'Link copied');
282
+ const copyFailLabel = prop('copyFailLabel', 'Copy the link below');
283
+ const copyFallbackValue = prop('copyFallback', 'input');
284
+ const copyFallback = copyFallbackValue === 'hide' ? 'hide' : 'input';
285
+ const confirmDuration = clampNumber(
286
+ getResponsiveValue(block.properties?.confirmDuration, currentBreakpoint, 2),
287
+ 2,
288
+ 1,
289
+ 15
290
+ );
291
+ const shareTextTemplate = prop('shareTextTemplate', '{title}');
292
+ const emailSubjectTemplate = prop('emailSubjectTemplate', '{title}');
293
+ const emailBodyTemplate = prop('emailBodyTemplate', '{title} {url}');
294
+ const iconSize = clampNumber(
295
+ getResponsiveValue(block.properties?.iconSize, currentBreakpoint, 20),
296
+ 20,
297
+ 12,
298
+ 64
299
+ );
300
+ const gap = clampNumber(
301
+ getResponsiveValue(block.properties?.gap, currentBreakpoint, 12),
302
+ 12,
303
+ 0,
304
+ 48
305
+ );
306
+ const openInNewTab = readToggle(
307
+ block.properties?.openInNewTab,
308
+ currentBreakpoint,
309
+ true
310
+ );
311
+ const baseUrl = prop('baseUrl', '');
312
+ const emptyLabel = prop('emptyLabel', 'Share (no networks enabled)');
313
+
314
+ const [isOpen, setIsOpen] = useState(false);
315
+ const [feedback, setFeedback] = useState<'copied' | 'failed' | null>(null);
316
+ const [fallbackUrl, setFallbackUrl] = useState('');
317
+ // Initialised to the optimistic answer so the server markup and the first
318
+ // client render agree; the effect below corrects it after hydration, which is
319
+ // the only moment `navigator` may be consulted safely.
320
+ const [clipboardAvailable, setClipboardAvailable] = useState(true);
321
+ const [browserUrl, setBrowserUrl] = useState({ origin: '', href: '' });
322
+ const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
323
+ const fallbackInput = useRef<HTMLInputElement | null>(null);
324
+
325
+ useEffect(() => {
326
+ setBrowserUrl({
327
+ origin: window.location.origin,
328
+ // The fragment is the shopper's scroll position, not part of what they
329
+ // meant to share.
330
+ href: window.location.href.split('#')[0]
331
+ });
332
+ setClipboardAvailable(
333
+ typeof navigator !== 'undefined' &&
334
+ typeof navigator.clipboard?.writeText === 'function'
335
+ );
336
+ }, []);
337
+
338
+ useEffect(
339
+ () => () => {
340
+ if (confirmTimer.current) clearTimeout(confirmTimer.current);
341
+ },
342
+ []
343
+ );
344
+
345
+ useEffect(() => {
346
+ if (!fallbackUrl || !fallbackInput.current) return;
347
+
348
+ fallbackInput.current.focus();
349
+ fallbackInput.current.select();
350
+ }, [fallbackUrl]);
351
+
352
+ const origin = browserUrl.origin || baseUrl;
353
+ const productPath = String(pickProductField<string>(product, 'url') ?? '');
354
+ const productImages = Array.isArray(product?.images) ? product?.images : [];
355
+ const productImagePath = String(
356
+ pickProductField<string>(product, 'image') ??
357
+ productImages?.[0]?.image ??
358
+ productImages?.[0]?.url ??
359
+ ''
360
+ );
361
+
362
+ const shareUrl = productPath
363
+ ? toAbsoluteUrl(productPath, origin)
364
+ : browserUrl.href || toAbsoluteUrl('/', origin);
365
+ const productName = getProductName(product);
366
+ const applyTemplate = (template: string) =>
367
+ template
368
+ .replace(/\{title\}/g, productName)
369
+ .replace(/\{url\}/g, shareUrl)
370
+ .trim();
371
+
372
+ const targets: ShareTargets = {
373
+ url: shareUrl,
374
+ text: applyTemplate(shareTextTemplate),
375
+ subject: applyTemplate(emailSubjectTemplate),
376
+ body: applyTemplate(emailBodyTemplate),
377
+ image: toAbsoluteUrl(productImagePath, origin)
378
+ };
379
+
380
+ const label = (network: ShareNetwork, fallback: string) =>
381
+ labelOverrides[network] || fallback;
382
+
383
+ const enabled = NETWORKS.filter((network) =>
384
+ readToggle(
385
+ block.properties?.[network.toggleKey],
386
+ currentBreakpoint,
387
+ network.enabledByDefault
388
+ )
389
+ ).filter(
390
+ // A copy option that cannot copy and has nothing to fall back on is a
391
+ // button that does nothing, so the author's choice is to hide it.
392
+ (network) =>
393
+ network.id !== 'copy' || clipboardAvailable || copyFallback === 'input'
394
+ );
395
+
396
+ if (enabled.length === 0) {
397
+ return isDesigner ? (
398
+ <DesignerPlaceholder label={emptyLabel} attribute="data-share" />
399
+ ) : null;
400
+ }
401
+
402
+ const handleCopy = async () => {
403
+ // Designer: no clipboard write, no timer, nothing written anywhere.
404
+ if (isDesigner) return;
405
+ if (!shareUrl) return;
406
+
407
+ const clipboard =
408
+ typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
409
+
410
+ if (clipboard && typeof clipboard.writeText === 'function') {
411
+ try {
412
+ await clipboard.writeText(shareUrl);
413
+ setFallbackUrl('');
414
+ setFeedback('copied');
415
+ if (confirmTimer.current) clearTimeout(confirmTimer.current);
416
+ confirmTimer.current = setTimeout(
417
+ () => setFeedback(null),
418
+ confirmDuration * 1000
419
+ );
420
+
421
+ return;
422
+ } catch {
423
+ // Denied permission, or a non-secure origin that exposes the API but
424
+ // refuses the write. Falling through is the honest outcome: claiming a
425
+ // copy that never happened is worse than admitting it.
426
+ }
427
+ }
428
+
429
+ setFeedback('failed');
430
+ setFallbackUrl(copyFallback === 'input' ? shareUrl : '');
431
+ };
432
+
433
+ const handleTrigger = async () => {
434
+ if (
435
+ !isDesigner &&
436
+ nativeShare &&
437
+ typeof navigator !== 'undefined' &&
438
+ typeof navigator.share === 'function'
439
+ ) {
440
+ try {
441
+ await navigator.share({
442
+ title: productName || undefined,
443
+ text: targets.text || undefined,
444
+ url: shareUrl || undefined
445
+ });
446
+
447
+ return;
448
+ } catch (error) {
449
+ // A dismissed sheet is a completed interaction, so the panel stays
450
+ // shut; anything else means the native path is unusable here and the
451
+ // icon row takes over.
452
+ if ((error as { name?: string })?.name === 'AbortError') return;
453
+ }
454
+ }
455
+
456
+ setIsOpen((current) => !current);
457
+ };
458
+
459
+ const usesTrigger = layout === 'trigger' || nativeShare;
460
+ const listVisible = !usesTrigger || isOpen;
461
+ const listId = `${block.id}-share-list`;
462
+
463
+ const glyph = (network: ShareNetwork | 'share') => (
464
+ <svg
465
+ width={iconSize}
466
+ height={iconSize}
467
+ viewBox="0 0 24 24"
468
+ fill="none"
469
+ stroke="currentColor"
470
+ strokeWidth={1.6}
471
+ strokeLinecap="round"
472
+ strokeLinejoin="round"
473
+ aria-hidden="true"
474
+ focusable="false"
475
+ {...partAttrs('icon')}
476
+ >
477
+ {GLYPHS[network]}
478
+ </svg>
479
+ );
480
+
481
+ const list = listVisible ? (
482
+ <div
483
+ id={listId}
484
+ data-share-list=""
485
+ {...partAttrs('list', { open: usesTrigger && isOpen })}
486
+ style={{
487
+ display: 'flex',
488
+ alignItems: 'center',
489
+ flexWrap: 'wrap',
490
+ gap: `${gap}px`,
491
+ ...(usesTrigger && panelPlacement !== 'inline'
492
+ ? {
493
+ position: 'absolute',
494
+ zIndex: 20,
495
+ left: 0,
496
+ ...(panelPlacement === 'above'
497
+ ? { bottom: 'calc(100% + 8px)' }
498
+ : { top: 'calc(100% + 8px)' }),
499
+ background: 'var(--share-panel-bg, #ffffff)',
500
+ border: '1px solid var(--share-panel-border, #e5e7eb)',
501
+ padding: '8px'
502
+ }
503
+ : null)
504
+ }}
505
+ >
506
+ {enabled.map((network) => {
507
+ if (network.id === 'copy') {
508
+ const copied = feedback === 'copied';
509
+
510
+ return (
511
+ <button
512
+ key={network.id}
513
+ type="button"
514
+ data-share-item="copy"
515
+ {...partAttrs('item', { checked: copied })}
516
+ aria-label={showLabels ? undefined : label('copy', copyLabel)}
517
+ onClick={handleCopy}
518
+ style={{
519
+ display: 'inline-flex',
520
+ alignItems: 'center',
521
+ gap: showLabels ? '8px' : 0,
522
+ border: 'none',
523
+ background: 'transparent',
524
+ color: 'inherit',
525
+ font: 'inherit',
526
+ padding: 0,
527
+ lineHeight: showLabels ? 'normal' : 0,
528
+ cursor: 'pointer'
529
+ }}
530
+ >
531
+ {glyph('copy')}
532
+ {showLabels && (
533
+ <span data-share-label="" {...partAttrs('label')}>
534
+ {label('copy', copyLabel)}
535
+ </span>
536
+ )}
537
+ </button>
538
+ );
539
+ }
540
+
541
+ const href = buildNetworkHref(network.id, targets);
542
+ const text = label(network.id, network.label);
543
+ // mailto never wants a second tab, and navigating inside the designer
544
+ // iframe would drop the author out of the page they are editing.
545
+ const opensNewTab =
546
+ !isDesigner && openInNewTab && network.id !== 'email';
547
+
548
+ return (
549
+ <a
550
+ key={network.id}
551
+ href={isDesigner ? '#' : href}
552
+ onClick={(event) => {
553
+ if (isDesigner) event.preventDefault();
554
+ }}
555
+ data-share-item={network.id}
556
+ {...partAttrs('item')}
557
+ aria-label={showLabels ? undefined : text}
558
+ title={showLabels ? undefined : text}
559
+ {...(opensNewTab
560
+ ? { target: '_blank', rel: 'noopener noreferrer' }
561
+ : {})}
562
+ style={{
563
+ display: 'inline-flex',
564
+ alignItems: 'center',
565
+ gap: showLabels ? '8px' : 0,
566
+ color: 'inherit',
567
+ textDecoration: 'none',
568
+ lineHeight: showLabels ? 'normal' : 0
569
+ }}
570
+ >
571
+ {glyph(network.id)}
572
+ {showLabels && (
573
+ <span data-share-label="" {...partAttrs('label')}>
574
+ {text}
575
+ </span>
576
+ )}
577
+ </a>
578
+ );
579
+ })}
580
+ </div>
581
+ ) : null;
582
+
583
+ return (
584
+ <div
585
+ data-share=""
586
+ data-share-layout={layout}
587
+ data-share-open={usesTrigger && isOpen ? 'true' : 'false'}
588
+ {...partAttrs('root', { open: usesTrigger && isOpen })}
589
+ style={{
590
+ display: 'flex',
591
+ alignItems: 'center',
592
+ flexWrap: 'wrap',
593
+ gap: `${gap}px`,
594
+ // Only a floating panel needs a positioned ancestor; an inline row must
595
+ // not silently become a containing block for whatever a brand puts
596
+ // inside it.
597
+ ...(usesTrigger && panelPlacement !== 'inline'
598
+ ? { position: 'relative' }
599
+ : null)
600
+ }}
601
+ >
602
+ {usesTrigger && (
603
+ <button
604
+ type="button"
605
+ data-share-trigger=""
606
+ {...partAttrs('trigger', { open: isOpen })}
607
+ aria-expanded={isOpen}
608
+ aria-controls={listVisible ? listId : undefined}
609
+ aria-label={triggerAriaLabel || undefined}
610
+ onClick={handleTrigger}
611
+ style={{
612
+ display: 'inline-flex',
613
+ alignItems: 'center',
614
+ gap: '8px',
615
+ border: 'none',
616
+ background: 'transparent',
617
+ color: 'inherit',
618
+ font: 'inherit',
619
+ padding: 0,
620
+ cursor: 'pointer'
621
+ }}
622
+ >
623
+ {showTriggerIcon && glyph('share')}
624
+ {triggerLabel && (
625
+ <span data-share-trigger-label="" {...partAttrs('label')}>
626
+ {triggerLabel}
627
+ </span>
628
+ )}
629
+ </button>
630
+ )}
631
+
632
+ {list}
633
+
634
+ {feedback && (
635
+ <span
636
+ data-share-feedback={feedback}
637
+ {...partAttrs('feedback', { invalid: feedback === 'failed' })}
638
+ role="status"
639
+ aria-live="polite"
640
+ >
641
+ {feedback === 'copied' ? copyConfirmLabel : copyFailLabel}
642
+ </span>
643
+ )}
644
+
645
+ {fallbackUrl && (
646
+ <input
647
+ ref={fallbackInput}
648
+ type="text"
649
+ readOnly
650
+ value={fallbackUrl}
651
+ data-share-fallback-input=""
652
+ {...partAttrs('fallback-input')}
653
+ onFocus={(event) => event.currentTarget.select()}
654
+ style={{
655
+ border: '1px solid var(--share-panel-border, #e5e7eb)',
656
+ background: 'transparent',
657
+ color: 'inherit',
658
+ font: 'inherit',
659
+ padding: '6px 8px'
660
+ }}
661
+ />
662
+ )}
663
+ </div>
664
+ );
665
+ };
666
+
667
+ export default ShareBlock;