@akinon/pz-theme 2.0.138-beta.0 → 2.0.138
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 +4 -4
- package/package.json +4 -4
- package/src/blocks/__tests__/link-block-interactive-nesting.test.tsx +337 -0
- package/src/blocks/favorite-button-block.tsx +5 -1
- package/src/blocks/link-block.tsx +103 -20
- package/src/components/with-designer-features.tsx +1 -1
- package/src/utils/index.ts +62 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.138
|
|
3
|
+
## 2.0.138
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- @akinon/next@2.0.138
|
|
9
|
-
- @akinon/theme-contract@2.0.138
|
|
7
|
+
- 4c44342: ZERO-5187: Render an action link block as a button role, not an anchor
|
|
8
|
+
- @akinon/next@2.0.138
|
|
9
|
+
- @akinon/theme-contract@2.0.138
|
|
10
10
|
|
|
11
11
|
## 2.0.137
|
|
12
12
|
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.138
|
|
4
|
+
"version": "2.0.138",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.138
|
|
11
|
+
"@akinon/next": "2.0.138",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@akinon/theme-contract": "2.0.138
|
|
16
|
+
"@akinon/theme-contract": "2.0.138",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"react-multi-carousel": "2.8.4",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"zod": "4.4.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@akinon/next": "2.0.138
|
|
24
|
+
"@akinon/next": "2.0.138",
|
|
25
25
|
"@testing-library/jest-dom": "5.16.4",
|
|
26
26
|
"@testing-library/react": "16.3.2",
|
|
27
27
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Recursive stub. Two child types are drawn as the real thing, because the
|
|
6
|
+
* whole point of these tests is what the ROOT does to interactive
|
|
7
|
+
* descendants: a `link` child reaches the real renderer, and a `button` child
|
|
8
|
+
* emits a native `<button>` the way `button-block` does. Everything else is a
|
|
9
|
+
* plain box that still forwards its own children.
|
|
10
|
+
*/
|
|
11
|
+
jest.mock('../../theme-block', () => {
|
|
12
|
+
const ReactLocal = require('react');
|
|
13
|
+
|
|
14
|
+
const Stub = ({ block, ...rest }: any): any => {
|
|
15
|
+
if (block?.type === 'link') {
|
|
16
|
+
const Real = require('../link-block').default;
|
|
17
|
+
return ReactLocal.createElement(Real, { block, ...rest });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (block?.type === 'button') {
|
|
21
|
+
return ReactLocal.createElement(
|
|
22
|
+
'button',
|
|
23
|
+
{ type: 'button', 'data-block-id': block.id },
|
|
24
|
+
block.label ?? 'CTA'
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return ReactLocal.createElement(
|
|
29
|
+
'div',
|
|
30
|
+
{ 'data-block-id': block?.id },
|
|
31
|
+
(block?.blocks ?? []).map((child: any) =>
|
|
32
|
+
ReactLocal.createElement(Stub, { key: child.id, block: child, ...rest })
|
|
33
|
+
)
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return { __esModule: true, default: Stub };
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
jest.mock('@akinon/next/hooks', () => ({
|
|
41
|
+
__esModule: true,
|
|
42
|
+
useLocalization: () => ({
|
|
43
|
+
t: (p: string) => p,
|
|
44
|
+
setLocale: () => {},
|
|
45
|
+
setCurrency: () => {},
|
|
46
|
+
locale: 'tr-tr',
|
|
47
|
+
currency: 'TRY',
|
|
48
|
+
locales: [{ value: 'tr-tr' }],
|
|
49
|
+
currencies: ['TRY'],
|
|
50
|
+
defaultLocaleValue: 'tr-tr',
|
|
51
|
+
defaultCurrencyCode: 'TRY',
|
|
52
|
+
localeUrlStrategy: 'hide-default-locale'
|
|
53
|
+
})
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
import LinkBlock from '../link-block';
|
|
57
|
+
import {
|
|
58
|
+
ACTION_ROOT_RESET_CSS,
|
|
59
|
+
compositionUsesActionRoot,
|
|
60
|
+
generateThemeCSS
|
|
61
|
+
} from '../../utils';
|
|
62
|
+
|
|
63
|
+
const VOID_ELEMENTS = new Set([
|
|
64
|
+
'area',
|
|
65
|
+
'base',
|
|
66
|
+
'br',
|
|
67
|
+
'col',
|
|
68
|
+
'embed',
|
|
69
|
+
'hr',
|
|
70
|
+
'img',
|
|
71
|
+
'input',
|
|
72
|
+
'link',
|
|
73
|
+
'meta',
|
|
74
|
+
'param',
|
|
75
|
+
'source',
|
|
76
|
+
'track',
|
|
77
|
+
'wbr'
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The invariant, checked on the markup the server actually streams.
|
|
82
|
+
*
|
|
83
|
+
* It cannot be checked through jsdom or Testing Library: both hand back the
|
|
84
|
+
* tree the HTML parser already REPAIRED, so an anchor nested in an anchor
|
|
85
|
+
* reads as perfectly valid there. That is why a hundred block tests never saw
|
|
86
|
+
* this. Walking the serialized string is the only place the authored nesting
|
|
87
|
+
* is still visible.
|
|
88
|
+
*
|
|
89
|
+
* Counting elements is not enough either: `<button>` inside `<button>` is
|
|
90
|
+
* repaired by MOVING the inner control out, which leaves the element count
|
|
91
|
+
* unchanged. Only the shape shows it.
|
|
92
|
+
*/
|
|
93
|
+
const invalidNestings = (html: string): string[] => {
|
|
94
|
+
const found: string[] = [];
|
|
95
|
+
const stack: string[] = [];
|
|
96
|
+
const tagPattern =
|
|
97
|
+
/<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/g;
|
|
98
|
+
|
|
99
|
+
let match: RegExpExecArray | null;
|
|
100
|
+
while ((match = tagPattern.exec(html)) !== null) {
|
|
101
|
+
const isClosing = Boolean(match[1]);
|
|
102
|
+
const name = match[2].toLowerCase();
|
|
103
|
+
const selfClosing = Boolean(match[4]);
|
|
104
|
+
|
|
105
|
+
if (VOID_ELEMENTS.has(name)) continue;
|
|
106
|
+
|
|
107
|
+
if (isClosing) {
|
|
108
|
+
const open = stack.lastIndexOf(name);
|
|
109
|
+
if (open >= 0) stack.length = open;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (selfClosing) continue;
|
|
114
|
+
|
|
115
|
+
// `<a>` is a formatting element, so the parser reparents a nested one.
|
|
116
|
+
// `<button>` inside `<button>` is repaired too. `<a>` inside `<button>`
|
|
117
|
+
// is not — but a span root makes all three moot, which is the point.
|
|
118
|
+
if (name === 'a' && stack.includes('a')) found.push('a-in-a');
|
|
119
|
+
if (name === 'button' && stack.includes('button')) {
|
|
120
|
+
found.push('button-in-button');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
stack.push(name);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return found;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const props = (block: Record<string, unknown>) =>
|
|
130
|
+
({
|
|
131
|
+
block,
|
|
132
|
+
placeholderId: 'p',
|
|
133
|
+
sectionId: 's',
|
|
134
|
+
isDesigner: false,
|
|
135
|
+
currentBreakpoint: 'desktop'
|
|
136
|
+
} as any);
|
|
137
|
+
|
|
138
|
+
const acting = (over: Record<string, unknown> = {}) => ({
|
|
139
|
+
id: 'trigger',
|
|
140
|
+
type: 'link',
|
|
141
|
+
properties: {
|
|
142
|
+
href: { desktop: '#' },
|
|
143
|
+
actionType: { desktop: 'open-modal' },
|
|
144
|
+
actionTarget: { desktop: 'm-ad7cbfp3' }
|
|
145
|
+
},
|
|
146
|
+
...over
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('link block element choice', () => {
|
|
150
|
+
it('renders a span with a button role when the block performs an action', () => {
|
|
151
|
+
const html = renderToStaticMarkup(<LinkBlock {...props(acting())} />);
|
|
152
|
+
|
|
153
|
+
expect(html).toContain('<span');
|
|
154
|
+
expect(html).toContain('role="button"');
|
|
155
|
+
expect(html).toContain('tabindex="0"');
|
|
156
|
+
expect(html).toContain('data-action-root');
|
|
157
|
+
// No dead destination in the status bar and none in the a11y tree.
|
|
158
|
+
expect(html).not.toContain('<a ');
|
|
159
|
+
expect(html).not.toContain('href=');
|
|
160
|
+
// A native button would reopen the nesting hazard this file exists for.
|
|
161
|
+
expect(html).not.toContain('<button');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('keeps a real anchor when the block navigates', () => {
|
|
165
|
+
const html = renderToStaticMarkup(
|
|
166
|
+
<LinkBlock
|
|
167
|
+
{...props({
|
|
168
|
+
id: 'nav',
|
|
169
|
+
type: 'link',
|
|
170
|
+
properties: { href: { desktop: '/yatak-odasi/' } }
|
|
171
|
+
})}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
expect(html).toContain('<a ');
|
|
176
|
+
expect(html).toContain('href="/yatak-odasi/"');
|
|
177
|
+
expect(html).not.toContain('role="button"');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('keeps the dialog aria on whichever element it renders', () => {
|
|
181
|
+
const html = renderToStaticMarkup(<LinkBlock {...props(acting())} />);
|
|
182
|
+
|
|
183
|
+
expect(html).toContain('aria-haspopup="dialog"');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('interactive nesting', () => {
|
|
188
|
+
/**
|
|
189
|
+
* The shape that took the zsa-zsa-zsu homepage down: a card whose root link
|
|
190
|
+
* opens a modal, with a second link nested inside it carrying `href: "#"`
|
|
191
|
+
* and no action of its own. Four of these shipped in section-q2jfk, and the
|
|
192
|
+
* parser turned 91 anchors into 103.
|
|
193
|
+
*/
|
|
194
|
+
const cardWithNestedLink = acting({
|
|
195
|
+
id: 'block-urclp',
|
|
196
|
+
blocks: [
|
|
197
|
+
{ id: 'block-sc9a5', type: 'image' },
|
|
198
|
+
{
|
|
199
|
+
id: 'block-s2px2',
|
|
200
|
+
type: 'group',
|
|
201
|
+
blocks: [
|
|
202
|
+
{
|
|
203
|
+
id: 'block-mznaq',
|
|
204
|
+
type: 'link',
|
|
205
|
+
properties: { href: { desktop: '#' } },
|
|
206
|
+
blocks: [{ id: 'block-pt6mw', type: 'icon' }]
|
|
207
|
+
}
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
]
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The regression a native `<button>` root would have introduced, and the
|
|
215
|
+
* reason the root is a span: an ordinary product card that opens a quick
|
|
216
|
+
* view and carries its own add-to-cart. This was SAFE before the element
|
|
217
|
+
* changed at all (`a > button` is not repaired), so turning it into
|
|
218
|
+
* `button > button` would have broken markup that worked.
|
|
219
|
+
*/
|
|
220
|
+
const cardWithNativeButton = acting({
|
|
221
|
+
id: 'card',
|
|
222
|
+
blocks: [
|
|
223
|
+
{ id: 'img', type: 'image' },
|
|
224
|
+
{
|
|
225
|
+
id: 'body',
|
|
226
|
+
type: 'group',
|
|
227
|
+
blocks: [
|
|
228
|
+
{ id: 'cta', type: 'button', label: 'Sepete ekle' },
|
|
229
|
+
{ id: 'price', type: 'text' }
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
]
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('emits no invalid nesting for the card that broke the homepage', () => {
|
|
236
|
+
const html = renderToStaticMarkup(<LinkBlock {...props(cardWithNestedLink)} />);
|
|
237
|
+
|
|
238
|
+
expect(invalidNestings(html)).toEqual([]);
|
|
239
|
+
expect(html.startsWith('<span')).toBe(true);
|
|
240
|
+
// The inner link stays a real link; only the root stopped being one.
|
|
241
|
+
expect((html.match(/<a /g) ?? []).length).toBe(1);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('emits no invalid nesting for an action card holding a native button', () => {
|
|
245
|
+
const html = renderToStaticMarkup(<LinkBlock {...props(cardWithNativeButton)} />);
|
|
246
|
+
|
|
247
|
+
expect(invalidNestings(html)).toEqual([]);
|
|
248
|
+
expect(html).toContain('<button type="button" data-block-id="cta"');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('still catches the nesting when neither link acts', () => {
|
|
252
|
+
// Guards the guard. With no action the root is an anchor again, and a
|
|
253
|
+
// nested link is the original defect — which only the descendant-side
|
|
254
|
+
// guard can fix, not the root element choice.
|
|
255
|
+
const html = renderToStaticMarkup(
|
|
256
|
+
<LinkBlock
|
|
257
|
+
{...props({
|
|
258
|
+
...cardWithNestedLink,
|
|
259
|
+
properties: { href: { desktop: '/koleksiyonlar/' } }
|
|
260
|
+
})}
|
|
261
|
+
/>
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
expect(invalidNestings(html)).toContain('a-in-a');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('detects button-in-button, so the native-button root cannot come back', () => {
|
|
268
|
+
// The branch above can only prove itself on markup that actually carries
|
|
269
|
+
// the shape. Element counts are equal here (5 and 5), which is exactly
|
|
270
|
+
// why a count-based check is not enough.
|
|
271
|
+
const repaired =
|
|
272
|
+
'<div><button type="button"><div><img></div>' +
|
|
273
|
+
'<button type="button">CTA</button></button></div>';
|
|
274
|
+
|
|
275
|
+
expect(invalidNestings(repaired)).toContain('button-in-button');
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('action root cursor rule', () => {
|
|
280
|
+
const composition = (blocks: unknown[]) => [{ id: 's1', blocks }] as any;
|
|
281
|
+
|
|
282
|
+
it('is emitted first, ahead of every section rule', () => {
|
|
283
|
+
const css = generateThemeCSS(
|
|
284
|
+
composition([
|
|
285
|
+
{
|
|
286
|
+
id: 'trigger',
|
|
287
|
+
type: 'link',
|
|
288
|
+
properties: { actionType: { desktop: 'open-modal' } }
|
|
289
|
+
}
|
|
290
|
+
])
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
expect(css.startsWith(ACTION_ROOT_RESET_CSS)).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('gives back the pointer an hrefless span does not get', () => {
|
|
297
|
+
expect(ACTION_ROOT_RESET_CSS).toContain('cursor:pointer');
|
|
298
|
+
// A span needs nothing else: it inherits typography and keeps the
|
|
299
|
+
// anchor's display. Resetting more would shadow authored styles.
|
|
300
|
+
expect(ACTION_ROOT_RESET_CSS).not.toContain('appearance');
|
|
301
|
+
expect(ACTION_ROOT_RESET_CSS).not.toContain('!important');
|
|
302
|
+
expect(ACTION_ROOT_RESET_CSS).not.toContain('data-block-id');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('is left out of a composition where nothing acts', () => {
|
|
306
|
+
const css = generateThemeCSS(
|
|
307
|
+
composition([
|
|
308
|
+
{ id: 'nav', type: 'link', properties: { href: { desktop: '/x/' } } }
|
|
309
|
+
])
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
expect(css).not.toContain('data-action-root');
|
|
313
|
+
expect(generateThemeCSS([])).toBe('');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('finds an acting link however deep it is nested', () => {
|
|
317
|
+
expect(
|
|
318
|
+
compositionUsesActionRoot(
|
|
319
|
+
composition([
|
|
320
|
+
{
|
|
321
|
+
id: 'group',
|
|
322
|
+
type: 'group',
|
|
323
|
+
blocks: [
|
|
324
|
+
// Scalar rather than per-breakpoint: both shapes reach the
|
|
325
|
+
// store, so both have to be recognised.
|
|
326
|
+
{
|
|
327
|
+
id: 'inner',
|
|
328
|
+
type: 'link',
|
|
329
|
+
properties: { actionType: 'scroll-to' }
|
|
330
|
+
}
|
|
331
|
+
]
|
|
332
|
+
}
|
|
333
|
+
])
|
|
334
|
+
)
|
|
335
|
+
).toBe(true);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
@@ -212,7 +212,11 @@ const FavoriteButtonBlock = ({
|
|
|
212
212
|
// click, the quick-view opener — which has nothing to do with navigation.
|
|
213
213
|
if (
|
|
214
214
|
event.currentTarget instanceof Element &&
|
|
215
|
-
|
|
215
|
+
// `[data-action-root]` is a link block whose action made it a span
|
|
216
|
+
// instead of an anchor — still a card whose action the heart must not
|
|
217
|
+
// fire. `link-block` also reads `defaultPrevented`, so the two guards
|
|
218
|
+
// cover each other.
|
|
219
|
+
event.currentTarget.closest('a, [data-action-root]')
|
|
216
220
|
) {
|
|
217
221
|
event.stopPropagation();
|
|
218
222
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from '../utils';
|
|
11
11
|
import { useThemeSettingsContext } from '../theme-settings-context';
|
|
12
12
|
import { useBlockAction } from '../hooks/use-block-action';
|
|
13
|
+
import { NATIVE_KEYBOARD_CONTROLS } from '../components/with-designer-features';
|
|
13
14
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
14
15
|
import ThemeBlock from '../theme-block';
|
|
15
16
|
|
|
@@ -38,6 +39,16 @@ const LinkBlock = ({
|
|
|
38
39
|
return;
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* A child that already handled this click owns it. The favourite heart
|
|
44
|
+
* cancels its own default and then stops the bubble only when it finds an
|
|
45
|
+
* ancestor anchor (`blocks/favorite-button-block.tsx`), so on a card whose
|
|
46
|
+
* root is not an anchor the heart's click arrived here as well and also
|
|
47
|
+
* fired the card's action — adding to favourites AND opening the modal.
|
|
48
|
+
* Reading `defaultPrevented` covers every such child, not just that one.
|
|
49
|
+
*/
|
|
50
|
+
if (event.defaultPrevented) return;
|
|
51
|
+
|
|
41
52
|
if (action.isActive) action.onClick?.(event);
|
|
42
53
|
};
|
|
43
54
|
|
|
@@ -108,34 +119,106 @@ const LinkBlock = ({
|
|
|
108
119
|
block.rowStyleKeys
|
|
109
120
|
);
|
|
110
121
|
|
|
111
|
-
|
|
112
|
-
|
|
122
|
+
/**
|
|
123
|
+
* A LINK BLOCK WHOSE JOB IS AN ACTION IS NOT A LINK.
|
|
124
|
+
*
|
|
125
|
+
* `useBlockAction` already cancels the element's default for every action it
|
|
126
|
+
* performs, so the href on such a block never navigates. The anchor was a
|
|
127
|
+
* control wearing a link's clothes: it announced itself as a link, put a
|
|
128
|
+
* destination in the status bar that no click would follow, and — because an
|
|
129
|
+
* anchor is a formatting element — made the HTML parser reparent any link a
|
|
130
|
+
* merchant nested inside it. That hands the browser a tree react-dom never
|
|
131
|
+
* rendered, and with no Suspense boundary over composed content React 18
|
|
132
|
+
* answers a hydration mismatch by client-rendering the whole document.
|
|
133
|
+
*
|
|
134
|
+
* IT IS A SPAN WITH role="button", NOT A NATIVE <button>. A native button
|
|
135
|
+
* looks like the obvious answer and is the wrong one: `<button>` inside
|
|
136
|
+
* `<button>` is repaired by the parser exactly as `<a>` inside `<a>` is, so
|
|
137
|
+
* an action card holding any button-emitting child — an add-to-cart, a
|
|
138
|
+
* favourite heart, a quick-view trigger — would trade one broken shape for
|
|
139
|
+
* another, and the element count stays the same, so a count-based check
|
|
140
|
+
* cannot even see it. A span is neither a formatting element nor a special
|
|
141
|
+
* one: it is never reparented and never reparents, whatever a merchant puts
|
|
142
|
+
* inside it. It also keeps the anchor's own box (`display: inline`) and
|
|
143
|
+
* inherits typography the same way, so nothing moves.
|
|
144
|
+
*
|
|
145
|
+
* `role="button"` plus a keyboard handler is the shape this package already
|
|
146
|
+
* uses for every other block that owns an action
|
|
147
|
+
* (`components/with-designer-features.tsx`).
|
|
148
|
+
*
|
|
149
|
+
* Navigation is untouched: a block with a destination and no action is still
|
|
150
|
+
* a real anchor. The choice reads the same properties on the server and on
|
|
151
|
+
* the first client render, so it cannot itself introduce a mismatch.
|
|
152
|
+
*/
|
|
153
|
+
const ownsAction = action.isActive;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Enter and Space, the two keys a button answers. A key pressed on a real
|
|
157
|
+
* control inside the card belongs to that control — cancelling it would
|
|
158
|
+
* swallow the control's own activation and make a space impossible to type
|
|
159
|
+
* into a field. Its click still bubbles here, so the keyboard does what the
|
|
160
|
+
* mouse does.
|
|
161
|
+
*/
|
|
162
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
|
163
|
+
if (!ownsAction || isDesigner) return;
|
|
164
|
+
|
|
165
|
+
if (
|
|
166
|
+
event.target !== event.currentTarget &&
|
|
167
|
+
event.target instanceof Element &&
|
|
168
|
+
event.target.matches(NATIVE_KEYBOARD_CONTROLS)
|
|
169
|
+
) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
174
|
+
|
|
175
|
+
// Space would scroll the page underneath the modal it just opened.
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
action.onClick?.(event as unknown as React.MouseEvent<HTMLElement>);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const rootProps = {
|
|
181
|
+
...partAttrs('root'),
|
|
182
|
+
style: inlineStyles,
|
|
183
|
+
onClick: handleClick,
|
|
184
|
+
...action.ariaProps
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const renderRoot = (children: React.ReactNode) =>
|
|
188
|
+
ownsAction ? (
|
|
189
|
+
<span
|
|
190
|
+
role="button"
|
|
191
|
+
tabIndex={0}
|
|
192
|
+
onKeyDown={handleKeyDown}
|
|
193
|
+
/**
|
|
194
|
+
* The hook the cursor rule in `generateThemeCSS` hangs on, and what a
|
|
195
|
+
* brand stylesheet written against `a` can be widened to. An hrefless
|
|
196
|
+
* span gets no pointer cursor from the browser.
|
|
197
|
+
*/
|
|
198
|
+
data-action-root=""
|
|
199
|
+
{...rootProps}
|
|
200
|
+
>
|
|
201
|
+
{children}
|
|
202
|
+
</span>
|
|
203
|
+
) : (
|
|
113
204
|
<NextLink
|
|
114
205
|
href={isDesigner ? '#' : formattedHref}
|
|
115
206
|
target={isDesigner ? undefined : target}
|
|
116
207
|
rel={isDesigner ? undefined : rel}
|
|
117
|
-
{...
|
|
118
|
-
style={inlineStyles}
|
|
119
|
-
onClick={handleClick}
|
|
120
|
-
{...action.ariaProps}
|
|
208
|
+
{...rootProps}
|
|
121
209
|
>
|
|
122
|
-
|
|
123
|
-
Empty link block
|
|
124
|
-
</div>
|
|
210
|
+
{children}
|
|
125
211
|
</NextLink>
|
|
126
212
|
);
|
|
213
|
+
|
|
214
|
+
if (!block.blocks || block.blocks.length === 0) {
|
|
215
|
+
return renderRoot(
|
|
216
|
+
<div style={{ padding: '20px', color: '#6b7280' }}>Empty link block</div>
|
|
217
|
+
);
|
|
127
218
|
}
|
|
128
219
|
|
|
129
|
-
return (
|
|
130
|
-
|
|
131
|
-
href={isDesigner ? '#' : formattedHref}
|
|
132
|
-
target={isDesigner ? undefined : target}
|
|
133
|
-
rel={isDesigner ? undefined : rel}
|
|
134
|
-
{...partAttrs('root')}
|
|
135
|
-
style={inlineStyles}
|
|
136
|
-
onClick={handleClick}
|
|
137
|
-
{...action.ariaProps}
|
|
138
|
-
>
|
|
220
|
+
return renderRoot(
|
|
221
|
+
<>
|
|
139
222
|
{block.blocks
|
|
140
223
|
.filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
|
|
141
224
|
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
@@ -189,7 +272,7 @@ const LinkBlock = ({
|
|
|
189
272
|
/>
|
|
190
273
|
);
|
|
191
274
|
})}
|
|
192
|
-
|
|
275
|
+
</>
|
|
193
276
|
);
|
|
194
277
|
};
|
|
195
278
|
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from '../utils/property-switches';
|
|
18
18
|
|
|
19
19
|
/** Elements that already act on Enter/Space themselves. */
|
|
20
|
-
const NATIVE_KEYBOARD_CONTROLS =
|
|
20
|
+
export const NATIVE_KEYBOARD_CONTROLS =
|
|
21
21
|
'a[href], button, input, select, textarea, summary, [contenteditable]:not([contenteditable="false"])';
|
|
22
22
|
|
|
23
23
|
interface WithDesignerFeaturesProps {
|
package/src/utils/index.ts
CHANGED
|
@@ -1792,6 +1792,61 @@ const collectBlockCSS = (
|
|
|
1792
1792
|
return css;
|
|
1793
1793
|
};
|
|
1794
1794
|
|
|
1795
|
+
/**
|
|
1796
|
+
* The one thing a link block loses when its action makes it a span rather
|
|
1797
|
+
* than an anchor: the pointer cursor the browser gives an href for free.
|
|
1798
|
+
*
|
|
1799
|
+
* Nothing else needs resetting — a span inherits typography and keeps the
|
|
1800
|
+
* anchor's `display: inline`, which is exactly why the action root is a span
|
|
1801
|
+
* and not a native `<button>` (see `blocks/link-block.tsx`). One attribute
|
|
1802
|
+
* selector is (0,1,0), under the (0,2,0) every section and block rule below
|
|
1803
|
+
* carries, so an authored `cursor` wins on specificity as well as on source
|
|
1804
|
+
* order.
|
|
1805
|
+
*/
|
|
1806
|
+
export const ACTION_ROOT_RESET_CSS =
|
|
1807
|
+
'[data-action-root]{cursor:pointer}\n';
|
|
1808
|
+
|
|
1809
|
+
/**
|
|
1810
|
+
* Mirrors `isInteractiveAction` in `hooks/use-block-action` — every action
|
|
1811
|
+
* type except the two that navigate — without importing it: that module reads
|
|
1812
|
+
* `getResponsiveValue` from this one, and the cycle would bite at module-init
|
|
1813
|
+
* time. Derived from the rule rather than from a copied list, so a new action
|
|
1814
|
+
* type is covered the day it is added.
|
|
1815
|
+
*
|
|
1816
|
+
* `link-block` is the only renderer that turns an action into a span root
|
|
1817
|
+
* today; widen this the day another one does.
|
|
1818
|
+
*/
|
|
1819
|
+
const isButtonRenderingAction = (value: unknown): boolean =>
|
|
1820
|
+
typeof value === 'string' &&
|
|
1821
|
+
value !== '' &&
|
|
1822
|
+
value !== 'none' &&
|
|
1823
|
+
value !== 'link';
|
|
1824
|
+
|
|
1825
|
+
/**
|
|
1826
|
+
* Deliberately broader than the renderer's own test, which also requires a
|
|
1827
|
+
* resolved target: a link block with an action and no target still renders as
|
|
1828
|
+
* an anchor, so the reset goes unused. An unused 170-byte rule costs nothing;
|
|
1829
|
+
* a missing one would let the browser's button chrome through.
|
|
1830
|
+
*/
|
|
1831
|
+
const blockUsesActionRoot = (block: Block): boolean => {
|
|
1832
|
+
if (block.type === 'link') {
|
|
1833
|
+
const authored = (block.properties as Record<string, unknown> | undefined)
|
|
1834
|
+
?.actionType;
|
|
1835
|
+
|
|
1836
|
+
const authoredPerBreakpoint =
|
|
1837
|
+
authored && typeof authored === 'object'
|
|
1838
|
+
? Object.values(authored as Record<string, unknown>)
|
|
1839
|
+
: [authored];
|
|
1840
|
+
|
|
1841
|
+
if (authoredPerBreakpoint.some(isButtonRenderingAction)) return true;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
return (block.blocks ?? []).some(blockUsesActionRoot);
|
|
1845
|
+
};
|
|
1846
|
+
|
|
1847
|
+
export const compositionUsesActionRoot = (sections: Section[]): boolean =>
|
|
1848
|
+
sections.some((section) => (section.blocks ?? []).some(blockUsesActionRoot));
|
|
1849
|
+
|
|
1795
1850
|
export const generateThemeCSS = (
|
|
1796
1851
|
sections: Section[],
|
|
1797
1852
|
targetBreakpoint?: string,
|
|
@@ -1808,7 +1863,13 @@ export const generateThemeCSS = (
|
|
|
1808
1863
|
// and they must lose a specificity tie to anything a section or block emits
|
|
1809
1864
|
// for itself, which source order settles. Emitted only when the composition
|
|
1810
1865
|
// actually carries a scoped break — see utils/line-breaks.ts.
|
|
1811
|
-
|
|
1866
|
+
// Same contract as the line-break rules below: global, emitted ahead of
|
|
1867
|
+
// every section selector so source order settles any specificity tie, and
|
|
1868
|
+
// only when the composition actually contains a link block that renders as
|
|
1869
|
+
// a button — see ACTION_ROOT_RESET_CSS.
|
|
1870
|
+
let css = compositionUsesActionRoot(sections) ? ACTION_ROOT_RESET_CSS : '';
|
|
1871
|
+
|
|
1872
|
+
css += compositionUsesScopedLineBreaks(sections)
|
|
1812
1873
|
? buildLineBreakCss(hatchBreakpoint)
|
|
1813
1874
|
: '';
|
|
1814
1875
|
|