@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.
@@ -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;