@akinon/pz-theme 2.0.100-beta.0 → 2.0.100
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.
- package/CHANGELOG.md +5 -4
- package/package.json +4 -4
- package/src/__tests__/registry-parity.test.ts +1 -0
- package/src/blocks/__tests__/collapsible-block.test.tsx +521 -0
- package/src/blocks/__tests__/image-block.test.tsx +173 -0
- package/src/blocks/block-renderer-registry.tsx +2 -0
- package/src/blocks/collapsible-block.tsx +595 -0
- package/src/blocks/image-block.tsx +150 -4
- package/src/utils/breakpoints.ts +30 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
12
|
+
import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
|
|
13
|
+
import {
|
|
14
|
+
MOBILE_MEDIA_QUERY_COMPACT,
|
|
15
|
+
TABLET_MEDIA_QUERY_COMPACT
|
|
16
|
+
} from '../utils/breakpoints';
|
|
17
|
+
import { escapeCssAttributeValue } from '../utils/css-safety';
|
|
18
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
19
|
+
import { readToggle } from './product-block-utils';
|
|
20
|
+
import { renderComposedChildren } from './render-composed-children';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Collapsible: clamps whatever is composed inside it to N lines, fades the last
|
|
24
|
+
* lines out, and offers a read-more trigger underneath.
|
|
25
|
+
*
|
|
26
|
+
* It has no content of its own. The article, its headings, the inline link and
|
|
27
|
+
* the FAQ accordion of a category page's SEO block are all ordinary child
|
|
28
|
+
* blocks, so each one keeps its own style panel — which is the thing a single
|
|
29
|
+
* rich-text blob cannot give you: an `<h2>` inside a `dangerouslySetInnerHTML`
|
|
30
|
+
* payload is unreachable from the inspector.
|
|
31
|
+
*
|
|
32
|
+
* THE HIDDEN HALF IS NEVER UNMOUNTED. Expanded and collapsed differ only in
|
|
33
|
+
* CSS. No `{isExpanded && …}`, no truncated string, no gated payload:
|
|
34
|
+
* `theme-placeholder.tsx` is `server-only` and hands `initialSections` straight
|
|
35
|
+
* into the client tree with no `mounted` gate, so the whole composition ships
|
|
36
|
+
* in the server HTML — which is the entire SEO requirement. `display:none`,
|
|
37
|
+
* the `hidden` attribute, `content-visibility:hidden` and `inert` are all
|
|
38
|
+
* forbidden on the clipped region for the same reason: each removes the text
|
|
39
|
+
* from the accessibility tree and from find-in-page as well as from the paint.
|
|
40
|
+
*
|
|
41
|
+
* Two mechanism choices that are load-bearing rather than stylistic:
|
|
42
|
+
*
|
|
43
|
+
* - `max-height` in `lh`, NOT `-webkit-line-clamp`. The clamp has to survive
|
|
44
|
+
* arbitrary composed children — headings, paragraphs, a nested FAQ list —
|
|
45
|
+
* and `-webkit-line-clamp` only counts line boxes of ONE inline formatting
|
|
46
|
+
* context. `display:-webkit-box` turns block children into box items, so
|
|
47
|
+
* there is nothing to count: the ellipsis silently never renders, the clamp
|
|
48
|
+
* lands on a child boundary, and the nested list loses block layout. `1lh`
|
|
49
|
+
* is one line of the clip box itself, so "8 lines" tracks whatever
|
|
50
|
+
* line-height the designer authored, with no duplicated constant.
|
|
51
|
+
* (`product-description-block.tsx` still does the `-webkit-line-clamp`
|
|
52
|
+
* thing; it survives only because a PDP description is usually one flat
|
|
53
|
+
* paragraph. Retiring it in favour of this block is a follow-up.)
|
|
54
|
+
*
|
|
55
|
+
* - `overflow: clip`, NOT `overflow: hidden`. A `hidden` box IS a scroll
|
|
56
|
+
* container, so focusing a link forty lines down scrolls the visible lines
|
|
57
|
+
* away and strands the shopper mid-paragraph with no way back. A `clip` box
|
|
58
|
+
* physically cannot scroll.
|
|
59
|
+
*
|
|
60
|
+
* Properties: collapsedLines, fadeLines, fadeStyle, fadeColor, moreLabel,
|
|
61
|
+
* lessLabel, duration, defaultExpanded, expandOnFocus, showToggleIcon,
|
|
62
|
+
* toggleAlign, emptyLabel.
|
|
63
|
+
* Parts: viewport, clip, content, fade, toggle, toggle-label, toggle-icon,
|
|
64
|
+
* empty.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Last-resort captions. These MIRROR the editor descriptor defaults in
|
|
69
|
+
* pz-dynamic-widget/src/config/blocks/collapsible-block-config.ts
|
|
70
|
+
* (`COLLAPSIBLE_TRIGGER_DEFAULTS`), and that repo's collapsible-block.test.ts
|
|
71
|
+
* pins the two to each other. The authored property always wins; this only
|
|
72
|
+
* covers JSON written before the property existed, or emitted without it.
|
|
73
|
+
*/
|
|
74
|
+
export const COLLAPSIBLE_FALLBACK_LABELS = {
|
|
75
|
+
more: 'Devamını Gör',
|
|
76
|
+
less: 'Devamını Gizle'
|
|
77
|
+
} as const;
|
|
78
|
+
|
|
79
|
+
const FADE_STYLES = ['mask', 'color'] as const;
|
|
80
|
+
type FadeStyle = (typeof FADE_STYLES)[number];
|
|
81
|
+
|
|
82
|
+
const ALIGNMENTS = ['start', 'center', 'end'] as const;
|
|
83
|
+
type Alignment = (typeof ALIGNMENTS)[number];
|
|
84
|
+
|
|
85
|
+
/** `margin`, not `text-align`: the trigger is a flex box, not an inline run. */
|
|
86
|
+
const TRIGGER_MARGIN: Record<Alignment, string> = {
|
|
87
|
+
start: '0 auto 0 0',
|
|
88
|
+
center: '0 auto',
|
|
89
|
+
end: '0 0 0 auto'
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const DEFAULT_COLLAPSED_LINES = 8;
|
|
93
|
+
const DEFAULT_FADE_LINES = 3;
|
|
94
|
+
const DEFAULT_DURATION = 320;
|
|
95
|
+
const DEFAULT_EMPTY_LABEL = 'Collapsible (add the blocks to clamp)';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A clamped integer read off a responsive property.
|
|
99
|
+
*
|
|
100
|
+
* Clamped rather than trusted because the value reaches the renderer from
|
|
101
|
+
* stored JSON, and a `max-height` of `calc(-4 * 1lh)` is an invalid
|
|
102
|
+
* declaration that drops the whole rule — i.e. no clamp at all, silently.
|
|
103
|
+
*/
|
|
104
|
+
const readNumber = (
|
|
105
|
+
value: unknown,
|
|
106
|
+
breakpoint: string,
|
|
107
|
+
fallback: number,
|
|
108
|
+
min: number,
|
|
109
|
+
max: number
|
|
110
|
+
): number => {
|
|
111
|
+
const raw = getResponsiveValue(value, breakpoint, fallback);
|
|
112
|
+
const parsed = typeof raw === 'number' ? raw : parseFloat(String(raw));
|
|
113
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
114
|
+
return Math.min(max, Math.max(min, Math.round(parsed)));
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A `localetext` property is stored raw — a plain string, a locale map, or a
|
|
119
|
+
* JSON string holding one.
|
|
120
|
+
*
|
|
121
|
+
* Third copy of a reader that already exists in `text-block.tsx` and
|
|
122
|
+
* `button-block.tsx`. Deliberate: unifying them means touching two shipped
|
|
123
|
+
* blocks, which is its own ticket. Kept byte-compatible with theirs so the
|
|
124
|
+
* eventual extraction is a move, not a rewrite.
|
|
125
|
+
*/
|
|
126
|
+
const readLocaleText = (
|
|
127
|
+
value: unknown,
|
|
128
|
+
locale: string,
|
|
129
|
+
defaultLocale: string,
|
|
130
|
+
fallback: string
|
|
131
|
+
): string => {
|
|
132
|
+
let candidate: unknown = value;
|
|
133
|
+
|
|
134
|
+
if (typeof candidate === 'string' && candidate.trim().startsWith('{')) {
|
|
135
|
+
try {
|
|
136
|
+
candidate = JSON.parse(candidate);
|
|
137
|
+
} catch {
|
|
138
|
+
return candidate as string;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (candidate && typeof candidate === 'object') {
|
|
143
|
+
const map = candidate as Record<string, unknown>;
|
|
144
|
+
const hit = map[locale] ?? map[defaultLocale] ?? Object.values(map)[0];
|
|
145
|
+
return typeof hit === 'string' && hit.trim() !== '' ? hit : fallback;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (typeof candidate === 'string' && candidate.trim() !== '') {
|
|
149
|
+
return candidate;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return fallback;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const CollapsibleBlock: React.FC<BlockRendererProps> = ({
|
|
156
|
+
block,
|
|
157
|
+
placeholderId,
|
|
158
|
+
sectionId,
|
|
159
|
+
isDesigner = false,
|
|
160
|
+
selectedBlockId,
|
|
161
|
+
currentBreakpoint = 'desktop'
|
|
162
|
+
}) => {
|
|
163
|
+
const { locale } = useLocalization();
|
|
164
|
+
const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE || 'en';
|
|
165
|
+
const properties = block.properties;
|
|
166
|
+
|
|
167
|
+
const fadeStyle = resolveOption<FadeStyle>(
|
|
168
|
+
properties?.fadeStyle,
|
|
169
|
+
currentBreakpoint,
|
|
170
|
+
'mask',
|
|
171
|
+
[...FADE_STYLES]
|
|
172
|
+
);
|
|
173
|
+
const toggleAlign = resolveOption<Alignment>(
|
|
174
|
+
properties?.toggleAlign,
|
|
175
|
+
currentBreakpoint,
|
|
176
|
+
'start',
|
|
177
|
+
[...ALIGNMENTS]
|
|
178
|
+
);
|
|
179
|
+
const fadeColor = String(
|
|
180
|
+
getResponsiveValue(properties?.fadeColor, currentBreakpoint, '') || ''
|
|
181
|
+
);
|
|
182
|
+
const duration = readNumber(
|
|
183
|
+
properties?.duration,
|
|
184
|
+
currentBreakpoint,
|
|
185
|
+
DEFAULT_DURATION,
|
|
186
|
+
0,
|
|
187
|
+
800
|
|
188
|
+
);
|
|
189
|
+
const defaultExpanded = readToggle(
|
|
190
|
+
properties?.defaultExpanded,
|
|
191
|
+
currentBreakpoint,
|
|
192
|
+
false
|
|
193
|
+
);
|
|
194
|
+
const expandOnFocus = readToggle(
|
|
195
|
+
properties?.expandOnFocus,
|
|
196
|
+
currentBreakpoint,
|
|
197
|
+
true
|
|
198
|
+
);
|
|
199
|
+
const showToggleIcon = readToggle(
|
|
200
|
+
properties?.showToggleIcon,
|
|
201
|
+
currentBreakpoint,
|
|
202
|
+
true
|
|
203
|
+
);
|
|
204
|
+
const emptyLabel = String(
|
|
205
|
+
getResponsiveValue(
|
|
206
|
+
properties?.emptyLabel,
|
|
207
|
+
currentBreakpoint,
|
|
208
|
+
DEFAULT_EMPTY_LABEL
|
|
209
|
+
)
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// Merchant copy, from config. The constant is only reached when the stored
|
|
213
|
+
// composition carries no caption at all.
|
|
214
|
+
const moreLabel = readLocaleText(
|
|
215
|
+
properties?.moreLabel,
|
|
216
|
+
locale,
|
|
217
|
+
defaultLocale,
|
|
218
|
+
COLLAPSIBLE_FALLBACK_LABELS.more
|
|
219
|
+
);
|
|
220
|
+
const lessLabel = readLocaleText(
|
|
221
|
+
properties?.lessLabel,
|
|
222
|
+
locale,
|
|
223
|
+
defaultLocale,
|
|
224
|
+
COLLAPSIBLE_FALLBACK_LABELS.less
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// Expanded in the canvas, so children below the clip line stay selectable.
|
|
228
|
+
const [isExpanded, setIsExpanded] = useState(defaultExpanded || isDesigner);
|
|
229
|
+
/*
|
|
230
|
+
* Presentation, not truth. SSR assumes the common case (the content DOES
|
|
231
|
+
* overflow) and the observer only ever takes the affordance AWAY, so a long
|
|
232
|
+
* article — what this block exists for — costs no layout shift, and a short
|
|
233
|
+
* one loses a button one frame after hydration.
|
|
234
|
+
*/
|
|
235
|
+
const [overflows, setOverflows] = useState(true);
|
|
236
|
+
|
|
237
|
+
const panelId = useId();
|
|
238
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
239
|
+
const clipRef = useRef<HTMLDivElement | null>(null);
|
|
240
|
+
const contentRef = useRef<HTMLDivElement | null>(null);
|
|
241
|
+
|
|
242
|
+
// Lands in a selector, so it is escaped the way every other scoped sheet in
|
|
243
|
+
// the package escapes an id (see `escapeCssAttributeValue`).
|
|
244
|
+
const scopeId = escapeCssAttributeValue(String(block.id || 'collapsible'));
|
|
245
|
+
|
|
246
|
+
const css = useMemo(() => {
|
|
247
|
+
const scope = `[data-pz-collapsible="${scopeId}"]`;
|
|
248
|
+
const clip = `${scope} [data-part="clip"]`;
|
|
249
|
+
|
|
250
|
+
const vars = (band: string) =>
|
|
251
|
+
`${scope}{` +
|
|
252
|
+
`--pz-collapse-lines:${readNumber(
|
|
253
|
+
properties?.collapsedLines,
|
|
254
|
+
band,
|
|
255
|
+
DEFAULT_COLLAPSED_LINES,
|
|
256
|
+
2,
|
|
257
|
+
40
|
|
258
|
+
)};` +
|
|
259
|
+
`--pz-collapse-fade:${readNumber(
|
|
260
|
+
properties?.fadeLines,
|
|
261
|
+
band,
|
|
262
|
+
DEFAULT_FADE_LINES,
|
|
263
|
+
0,
|
|
264
|
+
12
|
|
265
|
+
)};` +
|
|
266
|
+
`--pz-collapse-duration:${duration}ms}`;
|
|
267
|
+
|
|
268
|
+
/*
|
|
269
|
+
* The canvas draws a phone layout inside a desktop-wide frame, so a real
|
|
270
|
+
* media query would answer for the FRAME and the designer would author a
|
|
271
|
+
* mobile line count while seeing the desktop one. One resolved band in the
|
|
272
|
+
* designer, real queries on the storefront — the same split
|
|
273
|
+
* `buildLineBreakCss` makes for the same reason.
|
|
274
|
+
*/
|
|
275
|
+
const bands = isDesigner
|
|
276
|
+
? vars(currentBreakpoint)
|
|
277
|
+
: vars('desktop') +
|
|
278
|
+
`${MOBILE_MEDIA_QUERY_COMPACT}{${vars('mobile')}}` +
|
|
279
|
+
`${TABLET_MEDIA_QUERY_COMPACT}{${vars('tablet')}}`;
|
|
280
|
+
|
|
281
|
+
/*
|
|
282
|
+
* A mask, not a coloured gradient. This block sits INSIDE an authored
|
|
283
|
+
* section, and sections carry their own background colour or image — so
|
|
284
|
+
* even the correct page-level variable is the wrong colour half the time,
|
|
285
|
+
* and over a background image no solid colour is right at all. Masking
|
|
286
|
+
* fades the text itself, so whatever is painted behind shows through and
|
|
287
|
+
* is always correct. `fadeStyle: 'color'` is the escape hatch for brands
|
|
288
|
+
* that want an opaque band over a busy photo.
|
|
289
|
+
*/
|
|
290
|
+
const mask =
|
|
291
|
+
'linear-gradient(to bottom,#000 calc(100% - var(--pz-collapse-fade) * 1lh),transparent 100%)';
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
// Registered so the fade band is interpolable; re-registering the same
|
|
295
|
+
// descriptor from a second instance on the page is a no-op.
|
|
296
|
+
`@property --pz-collapse-fade{syntax:'<number>';inherits:true;initial-value:0}` +
|
|
297
|
+
bands +
|
|
298
|
+
`${clip}{` +
|
|
299
|
+
// The `em` line is the pre-`lh` fallback; the `lh` line overrides it
|
|
300
|
+
// wherever it parses, so declaration order here matters.
|
|
301
|
+
`max-height:calc(var(--pz-collapse-lines) * 1.5em);` +
|
|
302
|
+
`max-height:calc(var(--pz-collapse-lines) * 1lh);` +
|
|
303
|
+
`overflow:hidden;overflow:clip;` +
|
|
304
|
+
(fadeStyle === 'mask'
|
|
305
|
+
? `-webkit-mask-image:${mask};mask-image:${mask};` +
|
|
306
|
+
`-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;`
|
|
307
|
+
: '') +
|
|
308
|
+
`transition:max-height var(--pz-collapse-duration) cubic-bezier(.4,0,.2,1),` +
|
|
309
|
+
`--pz-collapse-fade var(--pz-collapse-duration) linear}` +
|
|
310
|
+
// Open: the measured natural height, with the fade band shrinking to
|
|
311
|
+
// zero in step with it instead of popping off on frame one.
|
|
312
|
+
`${clip}[data-state~="open"]{max-height:var(--pz-collapse-full,none);--pz-collapse-fade:0}` +
|
|
313
|
+
`${scope}[data-overflowing="false"] [data-part="clip"]{max-height:none;-webkit-mask-image:none;mask-image:none}` +
|
|
314
|
+
`${scope}[data-overflowing="false"] [data-part="toggle"]{display:none}` +
|
|
315
|
+
/*
|
|
316
|
+
* In the canvas an expanded clip box must not clip AT ALL: a clipping
|
|
317
|
+
* ancestor makes the editor's ActionToolbar flip inward, and a child one
|
|
318
|
+
* pixel past the box would be unselectable.
|
|
319
|
+
*/
|
|
320
|
+
`${scope}[data-designer-open="true"] [data-part="clip"]{max-height:none;overflow:visible;-webkit-mask-image:none;mask-image:none}` +
|
|
321
|
+
/*
|
|
322
|
+
* The colour band, declared here rather than inline so a merchant's
|
|
323
|
+
* `fade/*` part styles (which the generator rewrites `!important`) are
|
|
324
|
+
* not fighting an inline rule, and so the authored colour reaches CSS as
|
|
325
|
+
* a custom property VALUE on the root — it is never concatenated into
|
|
326
|
+
* this string, which is what keeps a crafted colour from closing the
|
|
327
|
+
* rule and writing its own.
|
|
328
|
+
*/
|
|
329
|
+
(fadeStyle === 'color'
|
|
330
|
+
? `${scope} [data-part="fade"]{position:absolute;left:0;right:0;bottom:0;` +
|
|
331
|
+
`height:calc(var(--pz-collapse-fade) * 1lh);pointer-events:none;` +
|
|
332
|
+
`background-image:linear-gradient(to bottom,transparent,var(--pz-collapse-fade-color,var(--theme-background,#fff)))}`
|
|
333
|
+
: '') +
|
|
334
|
+
`${scope} [data-part="toggle-icon"]{display:inline-block;transition:transform var(--pz-collapse-duration) ease}` +
|
|
335
|
+
`${scope} [data-part="toggle"][data-state~="open"] [data-part="toggle-icon"]{transform:rotate(180deg)}` +
|
|
336
|
+
`@media (prefers-reduced-motion:reduce){${clip},${scope} [data-part="toggle-icon"]{transition:none}}`
|
|
337
|
+
);
|
|
338
|
+
}, [currentBreakpoint, duration, fadeStyle, isDesigner, properties, scopeId]);
|
|
339
|
+
|
|
340
|
+
/*
|
|
341
|
+
* With JS off the button is inert, so a unit that exists to be READ would be
|
|
342
|
+
* permanently truncated for text-mode and proxy clients. SSR-ing expanded
|
|
343
|
+
* and collapsing on hydration would trade that for a guaranteed layout shift
|
|
344
|
+
* on every load for every user; this costs nothing, and it also removes the
|
|
345
|
+
* button that would not work.
|
|
346
|
+
*/
|
|
347
|
+
const noScriptCss = useMemo(
|
|
348
|
+
() =>
|
|
349
|
+
// `dangerouslySetInnerHTML` rather than a `<style>` child: `<noscript>`
|
|
350
|
+
// is a raw-text element, so a React element child does not survive the
|
|
351
|
+
// round trip through the DOM. The string carries no authored value —
|
|
352
|
+
// only the escaped id — so there is nothing here to escape further.
|
|
353
|
+
`<style>` +
|
|
354
|
+
`[data-pz-collapsible="${scopeId}"] [data-part="clip"]{max-height:none;-webkit-mask-image:none;mask-image:none}` +
|
|
355
|
+
`[data-pz-collapsible="${scopeId}"] [data-part="toggle"]{display:none}` +
|
|
356
|
+
`</style>`,
|
|
357
|
+
[scopeId]
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
const root = rootRef.current;
|
|
362
|
+
const clip = clipRef.current;
|
|
363
|
+
const content = contentRef.current;
|
|
364
|
+
if (!root || !clip || !content) return undefined;
|
|
365
|
+
if (typeof ResizeObserver === 'undefined') return undefined;
|
|
366
|
+
|
|
367
|
+
const measure = () => {
|
|
368
|
+
/*
|
|
369
|
+
* The CONTENT box, never `clip.scrollHeight`: an `overflow:clip` box is
|
|
370
|
+
* not a scroll container, so its scrollHeight is just its padding box
|
|
371
|
+
* and would always report "it fits".
|
|
372
|
+
*/
|
|
373
|
+
const natural = content.getBoundingClientRect().height;
|
|
374
|
+
|
|
375
|
+
const clipStyle = window.getComputedStyle(clip);
|
|
376
|
+
const parsedLine = parseFloat(clipStyle.lineHeight);
|
|
377
|
+
const line = Number.isFinite(parsedLine)
|
|
378
|
+
? parsedLine
|
|
379
|
+
: parseFloat(clipStyle.fontSize) * 1.5 || 24;
|
|
380
|
+
|
|
381
|
+
/*
|
|
382
|
+
* The budget comes from the RESOLVED custom property, not from
|
|
383
|
+
* `currentBreakpoint`: on the storefront the first client render still
|
|
384
|
+
* says 'desktop' (the wrapper corrects it in a post-mount effect) while
|
|
385
|
+
* the media query above has already answered for the real viewport.
|
|
386
|
+
*/
|
|
387
|
+
const lines =
|
|
388
|
+
parseFloat(
|
|
389
|
+
window.getComputedStyle(root).getPropertyValue('--pz-collapse-lines')
|
|
390
|
+
) || DEFAULT_COLLAPSED_LINES;
|
|
391
|
+
|
|
392
|
+
setOverflows(natural > lines * line + 1);
|
|
393
|
+
|
|
394
|
+
/*
|
|
395
|
+
* Kept live, so the box grows when a child faq-accordion row opens
|
|
396
|
+
* inside it instead of clipping its own children. The cost is two nested
|
|
397
|
+
* easings on the same pixels while that happens; the alternative
|
|
398
|
+
* (`max-height:none` on transitionend) removes it but `none` is not
|
|
399
|
+
* interpolable, so collapsing would then need a pin-and-reflow.
|
|
400
|
+
*/
|
|
401
|
+
const full = `${Math.ceil(natural)}px`;
|
|
402
|
+
if (root.style.getPropertyValue('--pz-collapse-full') !== full) {
|
|
403
|
+
root.style.setProperty('--pz-collapse-full', full);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
measure();
|
|
408
|
+
|
|
409
|
+
/*
|
|
410
|
+
* Only the content box. Observing the clip box would re-enter on every
|
|
411
|
+
* frame of the max-height transition.
|
|
412
|
+
*/
|
|
413
|
+
const observer = new ResizeObserver(measure);
|
|
414
|
+
observer.observe(content);
|
|
415
|
+
// Webfonts land after first paint and change every line count.
|
|
416
|
+
document.fonts?.ready.then(measure).catch(() => undefined);
|
|
417
|
+
|
|
418
|
+
return () => observer.disconnect();
|
|
419
|
+
}, [currentBreakpoint]);
|
|
420
|
+
|
|
421
|
+
/*
|
|
422
|
+
* A fragment link — an authored htmlId, a skip link, a shared deep link —
|
|
423
|
+
* can target a heading inside the clipped area, and the browser would
|
|
424
|
+
* happily scroll to a box that is not painted.
|
|
425
|
+
*/
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
if (isExpanded || typeof window === 'undefined') return undefined;
|
|
428
|
+
|
|
429
|
+
const reveal = () => {
|
|
430
|
+
const hash = window.location.hash.slice(1);
|
|
431
|
+
if (!hash) return;
|
|
432
|
+
let target: HTMLElement | null = null;
|
|
433
|
+
try {
|
|
434
|
+
target = document.getElementById(decodeURIComponent(hash));
|
|
435
|
+
} catch {
|
|
436
|
+
target = document.getElementById(hash);
|
|
437
|
+
}
|
|
438
|
+
if (target && contentRef.current?.contains(target)) setIsExpanded(true);
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
reveal();
|
|
442
|
+
window.addEventListener('hashchange', reveal);
|
|
443
|
+
return () => window.removeEventListener('hashchange', reveal);
|
|
444
|
+
}, [isExpanded]);
|
|
445
|
+
|
|
446
|
+
const prefersReducedMotion = () =>
|
|
447
|
+
typeof window !== 'undefined' &&
|
|
448
|
+
typeof window.matchMedia === 'function' &&
|
|
449
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
450
|
+
|
|
451
|
+
const collapse = useCallback(() => {
|
|
452
|
+
setIsExpanded(false);
|
|
453
|
+
const root = rootRef.current;
|
|
454
|
+
if (!root) return;
|
|
455
|
+
/*
|
|
456
|
+
* Collapsing removes thousands of pixels from above the shopper, who is
|
|
457
|
+
* then left looking at the footer. Focus stays on the trigger — only the
|
|
458
|
+
* viewport moves, and only if the block has already scrolled off the top.
|
|
459
|
+
*/
|
|
460
|
+
requestAnimationFrame(() => {
|
|
461
|
+
if (root.getBoundingClientRect().top >= 0) return;
|
|
462
|
+
root.scrollIntoView({
|
|
463
|
+
block: 'start',
|
|
464
|
+
behavior: prefersReducedMotion() ? 'auto' : 'smooth'
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
}, []);
|
|
468
|
+
|
|
469
|
+
/*
|
|
470
|
+
* Capture phase, so it runs before any child's own focus handler. A keyboard
|
|
471
|
+
* user reaching hidden content is doing exactly what a pointer user does by
|
|
472
|
+
* clicking the trigger, so do it for them — and because it goes through the
|
|
473
|
+
* same state, `aria-expanded` stays truthful. The trigger is a SIBLING of
|
|
474
|
+
* the viewport, so it can never self-trigger.
|
|
475
|
+
*/
|
|
476
|
+
const handleFocusCapture = useCallback(() => {
|
|
477
|
+
if (expandOnFocus) setIsExpanded(true);
|
|
478
|
+
}, [expandOnFocus]);
|
|
479
|
+
|
|
480
|
+
const children = renderComposedChildren({
|
|
481
|
+
blocks: block.blocks,
|
|
482
|
+
placeholderId,
|
|
483
|
+
sectionId,
|
|
484
|
+
isDesigner,
|
|
485
|
+
selectedBlockId,
|
|
486
|
+
currentBreakpoint
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
if (children.length === 0) {
|
|
490
|
+
if (!isDesigner) return null;
|
|
491
|
+
return (
|
|
492
|
+
<div
|
|
493
|
+
{...partAttrs('empty')}
|
|
494
|
+
className="p-4 text-gray-400 border border-dashed border-gray-300 rounded"
|
|
495
|
+
>
|
|
496
|
+
{emptyLabel}
|
|
497
|
+
</div>
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return (
|
|
502
|
+
<div
|
|
503
|
+
ref={rootRef}
|
|
504
|
+
data-pz-collapsible={scopeId}
|
|
505
|
+
data-overflowing={overflows ? 'true' : 'false'}
|
|
506
|
+
data-designer-open={isDesigner && isExpanded ? 'true' : 'false'}
|
|
507
|
+
style={
|
|
508
|
+
fadeColor
|
|
509
|
+
? ({ '--pz-collapse-fade-color': fadeColor } as React.CSSProperties)
|
|
510
|
+
: undefined
|
|
511
|
+
}
|
|
512
|
+
>
|
|
513
|
+
<style>{css}</style>
|
|
514
|
+
<noscript dangerouslySetInnerHTML={{ __html: noScriptCss }} />
|
|
515
|
+
|
|
516
|
+
<div
|
|
517
|
+
{...partAttrs('viewport')}
|
|
518
|
+
style={{ position: 'relative' }}
|
|
519
|
+
onFocusCapture={handleFocusCapture}
|
|
520
|
+
>
|
|
521
|
+
<div ref={clipRef} {...partAttrs('clip', { open: isExpanded })}>
|
|
522
|
+
{/*
|
|
523
|
+
`flow-root`, because `overflow: clip` — unlike `hidden` — does NOT
|
|
524
|
+
establish a block formatting context. Without it the first child's
|
|
525
|
+
top margin collapses straight out of the clip box and the clamp
|
|
526
|
+
starts one margin too low: exactly the kind of drift a wrapper
|
|
527
|
+
around arbitrary composed children invites.
|
|
528
|
+
*/}
|
|
529
|
+
<div
|
|
530
|
+
ref={contentRef}
|
|
531
|
+
id={panelId}
|
|
532
|
+
{...partAttrs('content')}
|
|
533
|
+
style={{ display: 'flow-root' }}
|
|
534
|
+
>
|
|
535
|
+
{children}
|
|
536
|
+
</div>
|
|
537
|
+
</div>
|
|
538
|
+
|
|
539
|
+
{fadeStyle === 'color' && (
|
|
540
|
+
<div
|
|
541
|
+
aria-hidden="true"
|
|
542
|
+
{...partAttrs('fade', { open: isExpanded })}
|
|
543
|
+
/>
|
|
544
|
+
)}
|
|
545
|
+
</div>
|
|
546
|
+
|
|
547
|
+
<button
|
|
548
|
+
type="button"
|
|
549
|
+
{...partAttrs('toggle', { open: isExpanded })}
|
|
550
|
+
aria-expanded={isExpanded}
|
|
551
|
+
aria-controls={panelId}
|
|
552
|
+
onClick={() => (isExpanded ? collapse() : setIsExpanded(true))}
|
|
553
|
+
style={{
|
|
554
|
+
display: 'flex',
|
|
555
|
+
alignItems: 'center',
|
|
556
|
+
gap: 6,
|
|
557
|
+
width: 'fit-content',
|
|
558
|
+
margin: TRIGGER_MARGIN[toggleAlign]
|
|
559
|
+
}}
|
|
560
|
+
>
|
|
561
|
+
{/*
|
|
562
|
+
No `aria-label`: the visible caption already changes between the two
|
|
563
|
+
states, and an aria-label on top would shadow it — breaking voice
|
|
564
|
+
control, where "click Devamını Gör" has to match the accessible name.
|
|
565
|
+
No `aria-live` either; every screen reader announces the
|
|
566
|
+
`aria-expanded` flip, and a live region would double-announce it.
|
|
567
|
+
*/}
|
|
568
|
+
<span {...partAttrs('toggle-label')}>
|
|
569
|
+
{isExpanded ? lessLabel : moreLabel}
|
|
570
|
+
</span>
|
|
571
|
+
{showToggleIcon && (
|
|
572
|
+
<span aria-hidden="true" {...partAttrs('toggle-icon')}>
|
|
573
|
+
<svg
|
|
574
|
+
width="10"
|
|
575
|
+
height="6"
|
|
576
|
+
viewBox="0 0 10 6"
|
|
577
|
+
fill="none"
|
|
578
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
579
|
+
>
|
|
580
|
+
<path
|
|
581
|
+
d="M1 1L5 5L9 1"
|
|
582
|
+
stroke="currentColor"
|
|
583
|
+
strokeWidth="1.5"
|
|
584
|
+
strokeLinecap="round"
|
|
585
|
+
strokeLinejoin="round"
|
|
586
|
+
/>
|
|
587
|
+
</svg>
|
|
588
|
+
</span>
|
|
589
|
+
)}
|
|
590
|
+
</button>
|
|
591
|
+
</div>
|
|
592
|
+
);
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
export default CollapsibleBlock;
|