@akinon/pz-theme 2.0.164-rc.1 → 2.0.164

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 (46) hide show
  1. package/CHANGELOG.md +8 -58
  2. package/package.json +4 -4
  3. package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
  4. package/src/__tests__/theme-settings-defaults.test.tsx +395 -0
  5. package/src/__tests__/theme-text-defaults-code-owned.test.tsx +340 -0
  6. package/src/blocks/__tests__/breadcrumb-theme-separator.test.tsx +148 -0
  7. package/src/blocks/__tests__/social-icons-theme-fallback.test.tsx +268 -0
  8. package/src/blocks/__tests__/text-block.test.tsx +6 -34
  9. package/src/blocks/accordion-block.tsx +10 -0
  10. package/src/blocks/hotspot-block.tsx +6 -1
  11. package/src/blocks/listing-breadcrumb-block.tsx +10 -1
  12. package/src/blocks/order-detail-block.tsx +95 -113
  13. package/src/blocks/product-breadcrumb-block.tsx +9 -1
  14. package/src/blocks/social-glyphs.tsx +31 -0
  15. package/src/blocks/social-icons-list-block.tsx +72 -8
  16. package/src/blocks/text-block.tsx +15 -6
  17. package/src/chrome/__tests__/theme-page-up.test.tsx +522 -0
  18. package/src/chrome/theme-page-up.tsx +303 -0
  19. package/src/chrome/use-editor-theme-settings.ts +104 -0
  20. package/src/components/with-designer-features.tsx +27 -0
  21. package/src/custom/README.md +219 -100
  22. package/src/custom/__tests__/use-theme-setting.test.tsx +0 -97
  23. package/src/custom/collect.ts +9 -4
  24. package/src/custom/property-types.ts +15 -15
  25. package/src/custom/use-theme-setting.ts +5 -21
  26. package/src/editor-locale-bridge.tsx +11 -39
  27. package/src/index.ts +15 -0
  28. package/src/overlays/theme-modals.tsx +10 -0
  29. package/src/sections/__tests__/section-wrapper.test.tsx +76 -0
  30. package/src/sections/coupon-banner-section.tsx +15 -1
  31. package/src/sections/section-wrapper.tsx +7 -0
  32. package/src/sections/tabs-section.tsx +13 -0
  33. package/src/theme-placeholder-wrapper.tsx +50 -3
  34. package/src/theme-section.tsx +15 -0
  35. package/src/utils/__tests__/svg-image-source.test.ts +123 -0
  36. package/src/utils/__tests__/theme-settings-css.test.ts +801 -0
  37. package/src/utils/__tests__/theme-settings-readers.test.ts +213 -0
  38. package/src/utils/__tests__/theme-settings-resolver.test.ts +232 -0
  39. package/src/utils/index.ts +36 -1
  40. package/src/utils/svg-image-source.ts +84 -0
  41. package/src/utils/theme-settings-css.ts +717 -0
  42. package/src/utils/theme-settings-readers.ts +205 -0
  43. package/src/blocks/__tests__/order-line-actions.test.tsx +0 -54
  44. package/src/blocks/order-line-actions.ts +0 -45
  45. package/src/utils/__tests__/sanitize-rich-text.test.ts +0 -80
  46. package/src/utils/sanitize-rich-text.ts +0 -215
@@ -0,0 +1,303 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { useLocalization } from '@akinon/next/hooks';
5
+
6
+ import { IconGlyph, resolveIconSource } from '../blocks/icon-source';
7
+ import { toSafeImageSource } from '../utils/svg-image-source';
8
+ import {
9
+ PAGE_UP_OFFSET_VARIABLE,
10
+ readThemePageUpSettings,
11
+ type ThemePageUpSettings
12
+ } from '../utils/theme-settings-readers';
13
+ import { useEditorThemeSettings } from './use-editor-theme-settings';
14
+
15
+ /**
16
+ * The "back to top" button the settings page's "Page up oku" switch turns on
17
+ * (`pageUpEnabled`, icon `pageUpIcon`).
18
+ *
19
+ * Mounted once per page by `ThemeModals`, the one global pz-theme mount every
20
+ * brand layout already renders, so a brand gets it with a version bump and no
21
+ * layout edit. Exported for a brand that does not mount `ThemeModals`.
22
+ */
23
+
24
+ const ICON_SIZE = 24;
25
+
26
+ /**
27
+ * The lift the button measured for itself, as a private variable on the
28
+ * button. It is only the fallback of the brand's offset variable, so a brand
29
+ * that sets that variable always wins, and the `bottom` declaration itself
30
+ * never changes after the server render.
31
+ */
32
+ const MEASURED_LIFT_VARIABLE = '--pz-page-up-lift';
33
+
34
+ /**
35
+ * Height of a fixed or sticky bar covering the bottom edge under the button,
36
+ * in px; 0 when there is none, when the brand sets the offset variable itself,
37
+ * or when the browser cannot hit-test.
38
+ *
39
+ * Only bars count: an element reaching above the middle of the viewport is an
40
+ * overlay (a drawer, a modal backdrop) that the button should not climb.
41
+ */
42
+ export const measureBottomOverlay = (button: HTMLElement | null): number => {
43
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
44
+ return 0;
45
+ }
46
+ if (typeof document.elementsFromPoint !== 'function') return 0;
47
+
48
+ if (
49
+ button &&
50
+ window
51
+ .getComputedStyle(button)
52
+ .getPropertyValue(PAGE_UP_OFFSET_VARIABLE)
53
+ .trim()
54
+ ) {
55
+ return 0;
56
+ }
57
+
58
+ const { innerWidth, innerHeight } = window;
59
+ const rect = button?.getBoundingClientRect();
60
+ // The button's own column (it may sit on the left in a right-to-left page).
61
+ const x =
62
+ rect && rect.width > 0 ? rect.left + rect.width / 2 : innerWidth - 40;
63
+ const size = rect && rect.height > 0 ? rect.height : 44;
64
+ // Two probes. The bottom edge finds a bar (an add-to-cart strip,
65
+ // an install prompt); the button's resting spot, 16px above that edge, finds
66
+ // a floating button parked in the same corner (a chat or WhatsApp launcher)
67
+ // that would otherwise sit on top of it.
68
+ const probes = [innerHeight - 8, innerHeight - 16 - size / 2];
69
+
70
+ let offset = 0;
71
+
72
+ probes.forEach((y, index) => {
73
+ for (const element of document.elementsFromPoint(x, y)) {
74
+ if (button && (element === button || button.contains(element))) continue;
75
+
76
+ const { position } = window.getComputedStyle(element);
77
+ if (position !== 'fixed' && position !== 'sticky') continue;
78
+
79
+ const box = element.getBoundingClientRect();
80
+ if (index === 0 && box.bottom < innerHeight - 1) continue;
81
+ if (box.top <= innerHeight / 2) continue;
82
+
83
+ offset = Math.max(offset, innerHeight - box.top);
84
+ }
85
+ });
86
+
87
+ return Math.ceil(Math.min(offset, innerHeight / 2));
88
+ };
89
+
90
+ const prefersReducedMotion = (): boolean =>
91
+ typeof window !== 'undefined' &&
92
+ typeof window.matchMedia === 'function' &&
93
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
94
+
95
+ /**
96
+ * After the jump, keyboard focus goes to the top of the content rather than
97
+ * staying on a button that is about to hide. A temporary `tabindex` makes the
98
+ * target focusable and is removed again once focus leaves it.
99
+ */
100
+ const focusPageTop = () => {
101
+ const target = document.querySelector<HTMLElement>('main') ?? document.body;
102
+
103
+ if (!target.hasAttribute('tabindex')) {
104
+ target.setAttribute('tabindex', '-1');
105
+ target.addEventListener('blur', () => target.removeAttribute('tabindex'), {
106
+ once: true
107
+ });
108
+ }
109
+
110
+ target.focus({ preventScroll: true });
111
+ };
112
+
113
+ const PageUpIcon = ({ settings }: { settings: ThemePageUpSettings }) => {
114
+ // `up-arrow` resolves the brand's uploaded override of that icon before the
115
+ // catalogue chevron, exactly as an icon block asking for it would.
116
+ const source = resolveIconSource(settings.pageUpIcon || 'up-arrow', settings);
117
+
118
+ if (source.kind === 'svg' || source.kind === 'url') {
119
+ // Never injected as markup: the icon is shared settings data and an SVG
120
+ // loaded as an image runs no script (utils/svg-image-source.ts).
121
+ const src = toSafeImageSource(
122
+ source.kind === 'svg' ? source.markup : source.url
123
+ );
124
+
125
+ if (src) {
126
+ return (
127
+ // eslint-disable-next-line @next/next/no-img-element
128
+ <img
129
+ src={src}
130
+ width={ICON_SIZE}
131
+ height={ICON_SIZE}
132
+ alt=""
133
+ aria-hidden="true"
134
+ data-page-up-icon="image"
135
+ style={{ display: 'block' }}
136
+ />
137
+ );
138
+ }
139
+ }
140
+
141
+ return (
142
+ <IconGlyph
143
+ {...(source.kind === 'glyph'
144
+ ? { glyph: source.glyph }
145
+ : { name: 'up-arrow' })}
146
+ size={ICON_SIZE}
147
+ data-page-up-icon="glyph"
148
+ />
149
+ );
150
+ };
151
+
152
+ const PageUpButton = ({ settings }: { settings: ThemePageUpSettings }) => {
153
+ const { locale } = useLocalization();
154
+ const label = String(locale || '')
155
+ .toLowerCase()
156
+ .startsWith('tr')
157
+ ? 'Sayfanın başına dön'
158
+ : 'Back to top';
159
+
160
+ const buttonRef = useRef<HTMLButtonElement>(null);
161
+ const [visible, setVisible] = useState(false);
162
+ const [measuredOffset, setMeasuredOffset] = useState(0);
163
+
164
+ /**
165
+ * Visible once the page has scrolled a full viewport. An observed sentinel
166
+ * rather than a scroll handler, as in chrome/scroll-state.tsx: the observer
167
+ * costs nothing until the sentinel crosses the viewport edge. Zero-sized so
168
+ * it can never add a scrollbar to a page shorter than 100vh.
169
+ */
170
+ useEffect(() => {
171
+ if (typeof IntersectionObserver === 'undefined') return undefined;
172
+
173
+ const sentinel = document.createElement('div');
174
+ sentinel.setAttribute('data-page-up-sentinel', '');
175
+ sentinel.setAttribute('aria-hidden', 'true');
176
+ sentinel.style.position = 'absolute';
177
+ sentinel.style.top = '100vh';
178
+ sentinel.style.left = '0px';
179
+ sentinel.style.width = '0px';
180
+ sentinel.style.height = '0px';
181
+ sentinel.style.pointerEvents = 'none';
182
+ document.body.appendChild(sentinel);
183
+
184
+ const observer = new IntersectionObserver((entries) => {
185
+ const entry = entries[entries.length - 1];
186
+ if (!entry) return;
187
+ // Out of view AND above it: below the fold (a fresh page) is not
188
+ // "scrolled past".
189
+ setVisible(!entry.isIntersecting && entry.boundingClientRect.top < 0);
190
+ });
191
+ observer.observe(sentinel);
192
+
193
+ return () => {
194
+ observer.disconnect();
195
+ sentinel.remove();
196
+ };
197
+ }, []);
198
+
199
+ /** Re-measured when the button shows and on resize, one frame at most. */
200
+ useEffect(() => {
201
+ if (!visible) return undefined;
202
+
203
+ let frame = 0;
204
+ const measure = () => {
205
+ frame = 0;
206
+ setMeasuredOffset(measureBottomOverlay(buttonRef.current));
207
+ };
208
+ const schedule = () => {
209
+ if (typeof window.requestAnimationFrame !== 'function') {
210
+ measure();
211
+ return;
212
+ }
213
+ if (!frame) frame = window.requestAnimationFrame(measure);
214
+ };
215
+
216
+ schedule();
217
+ window.addEventListener('resize', schedule);
218
+
219
+ return () => {
220
+ window.removeEventListener('resize', schedule);
221
+ if (frame && typeof window.cancelAnimationFrame === 'function') {
222
+ window.cancelAnimationFrame(frame);
223
+ }
224
+ };
225
+ }, [visible]);
226
+
227
+ const onClick = () => {
228
+ window.scrollTo({
229
+ top: 0,
230
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth'
231
+ });
232
+ focusPageTop();
233
+ };
234
+
235
+ const style = {
236
+ [MEASURED_LIFT_VARIABLE]: `${measuredOffset}px`,
237
+ position: 'fixed',
238
+ // Logical, so a right-to-left page gets it on its trailing side too.
239
+ insetInlineEnd:
240
+ 'max(16px, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))',
241
+ bottom: `calc(16px + var(${PAGE_UP_OFFSET_VARIABLE}, var(${MEASURED_LIFT_VARIABLE}, 0px)) + env(safe-area-inset-bottom, 0px))`,
242
+ zIndex: 30,
243
+ width: 44,
244
+ height: 44,
245
+ margin: 0,
246
+ padding: 0,
247
+ display: 'inline-flex',
248
+ alignItems: 'center',
249
+ justifyContent: 'center',
250
+ boxSizing: 'border-box',
251
+ borderRadius: '50%',
252
+ border: '1px solid var(--theme-border, #e2e2e2)',
253
+ background: 'var(--theme-background, #fff)',
254
+ color: 'var(--theme-heading, #111)',
255
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.12)',
256
+ cursor: 'pointer',
257
+ opacity: visible ? 1 : 0,
258
+ visibility: visible ? 'visible' : 'hidden',
259
+ pointerEvents: visible ? 'auto' : 'none',
260
+ // A fade, not a movement, so it stays under reduced motion.
261
+ transition: 'opacity 150ms ease, visibility 150ms ease'
262
+ } as React.CSSProperties;
263
+
264
+ return (
265
+ <button
266
+ ref={buttonRef}
267
+ type="button"
268
+ data-theme-page-up=""
269
+ aria-label={label}
270
+ title={label}
271
+ onClick={onClick}
272
+ // Hidden, it must not be reachable either: no tab stop, nothing read.
273
+ aria-hidden={visible ? undefined : true}
274
+ tabIndex={visible ? undefined : -1}
275
+ style={style}
276
+ >
277
+ <PageUpIcon settings={settings} />
278
+ </button>
279
+ );
280
+ };
281
+
282
+ export interface ThemePageUpProps {
283
+ /**
284
+ * The theme settings, whole or already narrowed with
285
+ * `readThemePageUpSettings`; only the page-up keys are read.
286
+ */
287
+ settings?: Record<string, unknown> | null;
288
+ }
289
+
290
+ /**
291
+ * Renders nothing unless `pageUpEnabled` is true. In the designer the
292
+ * editor's live settings replace the saved ones as soon as they arrive.
293
+ */
294
+ export function ThemePageUp({ settings = null }: ThemePageUpProps) {
295
+ const saved = useMemo(() => readThemePageUpSettings(settings), [settings]);
296
+ const effective = useEditorThemeSettings(saved);
297
+
298
+ if (effective.pageUpEnabled !== true) return null;
299
+
300
+ return <PageUpButton settings={effective} />;
301
+ }
302
+
303
+ export default ThemePageUp;
@@ -0,0 +1,104 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ import { parseEditorMessage } from '../editor-bridge';
6
+ import {
7
+ readThemePageUpSettings,
8
+ type ThemePageUpSettings
9
+ } from '../utils/theme-settings-readers';
10
+
11
+ /**
12
+ * The page-up settings as the EDITOR currently has them.
13
+ *
14
+ * The back-to-top button is mounted by `ThemeModals`, a server component
15
+ * outside every placeholder, so it never sees the `LOAD_THEME`/`UPDATE_THEME`
16
+ * frames a placeholder wrapper turns into live settings. Without this the
17
+ * designer would flip "Page up oku" and see nothing until the theme was saved
18
+ * and the canvas reloaded.
19
+ *
20
+ * Module state, like `overlays/use-editor-modals.ts` and for the same reason:
21
+ * every mount, in whichever React root, must agree on one answer.
22
+ */
23
+ let editorSettings: ThemePageUpSettings | null = null;
24
+
25
+ /**
26
+ * Serialized form of the slice. `UPDATE_THEME` arrives on every keystroke
27
+ * anywhere in the theme and almost none of those touch these keys, so the
28
+ * button only re-renders when the slice actually changes.
29
+ */
30
+ let signature: string | null = null;
31
+
32
+ const listeners = new Set<() => void>();
33
+
34
+ const setEditorSettings = (next: ThemePageUpSettings) => {
35
+ const nextSignature = JSON.stringify(next);
36
+ if (nextSignature === signature) return;
37
+
38
+ signature = nextSignature;
39
+ editorSettings = next;
40
+ listeners.forEach((listener) => listener());
41
+ };
42
+
43
+ let listening = false;
44
+
45
+ const handleMessage = (event: MessageEvent) => {
46
+ const message = parseEditorMessage(event);
47
+ if (!message) return;
48
+ if (message.type !== 'LOAD_THEME' && message.type !== 'UPDATE_THEME') {
49
+ return;
50
+ }
51
+
52
+ const settings = (
53
+ message.data as { theme?: { settings?: unknown } } | undefined
54
+ )?.theme?.settings;
55
+ // A frame without settings says nothing about them; the placeholder
56
+ // wrapper ignores it the same way.
57
+ if (!settings || typeof settings !== 'object') return;
58
+
59
+ setEditorSettings(
60
+ readThemePageUpSettings(settings as Record<string, unknown>)
61
+ );
62
+ };
63
+
64
+ const startListening = () => {
65
+ if (listening || typeof window === 'undefined') return;
66
+ listening = true;
67
+ window.addEventListener('message', handleMessage);
68
+ };
69
+
70
+ /**
71
+ * The editor's page-up slice once one has been heard, otherwise `initial` —
72
+ * which is what the server rendered, so the first client render matches it.
73
+ */
74
+ export const useEditorThemeSettings = (
75
+ initial: ThemePageUpSettings
76
+ ): ThemePageUpSettings => {
77
+ const [heard, setHeard] = useState<ThemePageUpSettings | null>(null);
78
+
79
+ useEffect(() => {
80
+ startListening();
81
+
82
+ const listener = () => setHeard(editorSettings);
83
+ listeners.add(listener);
84
+ // A frame may have landed between render and this effect.
85
+ if (editorSettings) listener();
86
+
87
+ return () => {
88
+ listeners.delete(listener);
89
+ };
90
+ }, []);
91
+
92
+ return heard ?? initial;
93
+ };
94
+
95
+ /** Test-only: drop what was heard and the listener. */
96
+ export const __resetEditorThemeSettingsForTests = (): void => {
97
+ editorSettings = null;
98
+ signature = null;
99
+ listeners.clear();
100
+ if (listening && typeof window !== 'undefined') {
101
+ window.removeEventListener('message', handleMessage);
102
+ }
103
+ listening = false;
104
+ };
@@ -19,6 +19,13 @@ import {
19
19
  HIDE_IN_CARD_VIEW_ATTRIBUTE,
20
20
  HIDE_IN_CARD_VIEW_PROPERTY
21
21
  } from '../utils/card-media';
22
+ // Own-colour / own-typography markers for the theme's rich-text
23
+ // element defaults.
24
+ import { useThemeSettingsContext } from '../theme-settings-context';
25
+ import {
26
+ type ThemeOwnTextStyle,
27
+ themeOwnStyleAttributes
28
+ } from '../utils/theme-settings-css';
22
29
 
23
30
  /** Elements that already act on Enter/Space themselves. */
24
31
  export const NATIVE_KEYBOARD_CONTROLS =
@@ -39,6 +46,11 @@ interface WithDesignerFeaturesProps {
39
46
  onToggleVisibility?: () => void;
40
47
  onDelete?: () => void;
41
48
  onRename?: (newLabel: string) => void;
49
+ /**
50
+ * Text colour or typography the caller paints on this wrapper with classes,
51
+ * which neither the block's styles nor `style` can show.
52
+ */
53
+ ownTextStyle?: ThemeOwnTextStyle;
42
54
  // Note: repo has mixed React type versions; keep this permissive to avoid ReactNode incompatibilities.
43
55
  children: any;
44
56
  }
@@ -58,9 +70,14 @@ export function WithDesignerFeatures({
58
70
  onToggleVisibility,
59
71
  onDelete,
60
72
  onRename,
73
+ ownTextStyle,
61
74
  children
62
75
  }: WithDesignerFeaturesProps) {
63
76
  const blockRef = useRef<HTMLDivElement>(null);
77
+ // Read here, not in the renderers: this wrapper encloses every
78
+ // element the block's root styles reach (its own box, or the inner root of
79
+ // a text/link/button), so a marker on it covers the authored values.
80
+ const themeSettings = useThemeSettingsContext();
64
81
  const actionBlockId = block.styleSourceId || block.id;
65
82
  const { handleClick } = useDesignerFeatures({
66
83
  blockId: actionBlockId,
@@ -182,6 +199,15 @@ export function WithDesignerFeatures({
182
199
  ...(ownsAction && !isDesigner ? { cursor: 'pointer' } : null)
183
200
  };
184
201
 
202
+ // Data-pz-own-color / data-pz-own-type, so the theme's rich-text
203
+ // defaults leave the colour and typography this block authored to inherit.
204
+ // `style` counts too: a section paints a code-owned box's text colour there.
205
+ const ownStyleAttributes = themeOwnStyleAttributes(
206
+ { ...block.styles, ...style },
207
+ themeSettings,
208
+ ownTextStyle
209
+ );
210
+
185
211
  return (
186
212
  <div
187
213
  ref={blockRef}
@@ -191,6 +217,7 @@ export function WithDesignerFeatures({
191
217
  {...(hideInCardView
192
218
  ? { [HIDE_IN_CARD_VIEW_ATTRIBUTE]: hideInCardView }
193
219
  : null)}
220
+ {...ownStyleAttributes}
194
221
  className={clsx(
195
222
  'relative',
196
223
  className,