@akinon/pz-theme 2.0.139-rc.0 → 2.0.139
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
CHANGED
|
@@ -1,40 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.139
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
- Updated dependencies [51ea06888]
|
|
11
|
-
- Updated dependencies
|
|
12
|
-
- Updated dependencies [2fea41430]
|
|
13
|
-
- Updated dependencies [b55acb768]
|
|
14
|
-
- Updated dependencies [760258c1c]
|
|
15
|
-
- Updated dependencies [143be2b9d]
|
|
16
|
-
- Updated dependencies [7889b08fe]
|
|
17
|
-
- Updated dependencies [9f8cd3bc5]
|
|
18
|
-
- Updated dependencies [bfafa3f49]
|
|
19
|
-
- Updated dependencies [57d7eb305]
|
|
20
|
-
- Updated dependencies [d99a6a7d5]
|
|
21
|
-
- Updated dependencies [9db81a714]
|
|
22
|
-
- Updated dependencies [591e345e1]
|
|
23
|
-
- Updated dependencies [4de5303c5]
|
|
24
|
-
- Updated dependencies [95b139dc1]
|
|
25
|
-
- Updated dependencies [1d00f2d06]
|
|
26
|
-
- Updated dependencies [4ac7b2a1e]
|
|
27
|
-
- Updated dependencies [e9598c71d]
|
|
28
|
-
- Updated dependencies [4998a9631]
|
|
29
|
-
- Updated dependencies [804d2bd6c]
|
|
30
|
-
- Updated dependencies [3909d3224]
|
|
31
|
-
- Updated dependencies [6a3d8a631]
|
|
32
|
-
- Updated dependencies [e18836b20]
|
|
33
|
-
- Updated dependencies [b1111d7b1]
|
|
34
|
-
- Updated dependencies [1595d458e]
|
|
35
|
-
- Updated dependencies [dca128112]
|
|
36
|
-
- @akinon/next@2.0.139-rc.0
|
|
37
|
-
- @akinon/theme-contract@2.0.139-rc.0
|
|
3
|
+
## 2.0.139
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 1bd9583: ZERO-5188: Refuse to render an anchor inside another anchor
|
|
8
|
+
- @akinon/next@2.0.139
|
|
9
|
+
- @akinon/theme-contract@2.0.139
|
|
38
10
|
|
|
39
11
|
## 2.0.138
|
|
40
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.139
|
|
4
|
+
"version": "2.0.139",
|
|
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.139
|
|
11
|
+
"@akinon/next": "2.0.139",
|
|
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.139
|
|
16
|
+
"@akinon/theme-contract": "2.0.139",
|
|
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.139
|
|
24
|
+
"@akinon/next": "2.0.139",
|
|
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",
|
|
@@ -248,10 +248,10 @@ describe('interactive nesting', () => {
|
|
|
248
248
|
expect(html).toContain('<button type="button" data-block-id="cta"');
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
it('
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
251
|
+
it('refuses to nest an anchor when neither link acts', () => {
|
|
252
|
+
// The original defect: with no action the root is a real anchor again,
|
|
253
|
+
// and the merchant nested a second link inside it. The descendant guard
|
|
254
|
+
// in ThemeLink is what covers this one — the root element choice cannot.
|
|
255
255
|
const html = renderToStaticMarkup(
|
|
256
256
|
<LinkBlock
|
|
257
257
|
{...props({
|
|
@@ -261,7 +261,13 @@ describe('interactive nesting', () => {
|
|
|
261
261
|
/>
|
|
262
262
|
);
|
|
263
263
|
|
|
264
|
-
expect(invalidNestings(html)).
|
|
264
|
+
expect(invalidNestings(html)).toEqual([]);
|
|
265
|
+
// The outer link keeps the destination: it is what a shopper aims at and
|
|
266
|
+
// what a crawler should follow.
|
|
267
|
+
expect(html).toContain('href="/koleksiyonlar/"');
|
|
268
|
+
// The inner one degraded rather than disappearing.
|
|
269
|
+
expect(html).toContain('data-nested-link');
|
|
270
|
+
expect((html.match(/<a /g) ?? []).length).toBe(1);
|
|
265
271
|
});
|
|
266
272
|
|
|
267
273
|
it('detects button-in-button, so the native-button root cannot come back', () => {
|
|
@@ -207,6 +207,12 @@ const LinkBlock = ({
|
|
|
207
207
|
rel={isDesigner ? undefined : rel}
|
|
208
208
|
{...rootProps}
|
|
209
209
|
>
|
|
210
|
+
{/*
|
|
211
|
+
* No anchor guard needed here: `ThemeLink` declares the open anchor
|
|
212
|
+
* for its own subtree, so every link in the package is covered at
|
|
213
|
+
* one place. The action branch above renders a span, which can
|
|
214
|
+
* legally hold a link, and correctly declares nothing.
|
|
215
|
+
*/}
|
|
210
216
|
{children}
|
|
211
217
|
</NextLink>
|
|
212
218
|
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether an anchor is already open above this point in the composed tree.
|
|
7
|
+
*
|
|
8
|
+
* `<a>` is an HTML formatting element: an anchor nested inside another is not
|
|
9
|
+
* merely invalid, the parser REPARENTS it, so the DOM the browser builds stops
|
|
10
|
+
* matching the markup react-dom streamed. With no Suspense boundary over
|
|
11
|
+
* composed content React 18 answers that mismatch by client-rendering the
|
|
12
|
+
* whole document — one nested link a merchant dragged into a card takes the
|
|
13
|
+
* entire page down.
|
|
14
|
+
*
|
|
15
|
+
* A composition is authored, not written, so the renderer cannot assume the
|
|
16
|
+
* shape is legal. This context lets the anchor that is already open say so,
|
|
17
|
+
* and `components/theme-link.tsx` — the one place every link in the package
|
|
18
|
+
* goes through — renders something that is not an anchor instead.
|
|
19
|
+
*
|
|
20
|
+
* Only ANCHORS provide it. A `<span role="button">` action root may hold a
|
|
21
|
+
* real link, and `<a>` inside `<button>` is not reparented either; the pair
|
|
22
|
+
* that breaks is anchor-in-anchor (and button-in-button, which the package
|
|
23
|
+
* cannot author because no button-rooted block renders child blocks).
|
|
24
|
+
*/
|
|
25
|
+
const AnchorAncestorContext = createContext(false);
|
|
26
|
+
|
|
27
|
+
/** True when the nearest composed ancestor already rendered an `<a>`. */
|
|
28
|
+
export const useInsideAnchor = (): boolean => useContext(AnchorAncestorContext);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Wrap the CHILDREN of an element that is a real anchor. Rendered from the
|
|
32
|
+
* same branch that chose the anchor, so the signal always describes the
|
|
33
|
+
* element that actually shipped rather than the block's type — a link block
|
|
34
|
+
* performing an action renders a span and must not claim an anchor is open.
|
|
35
|
+
*/
|
|
36
|
+
export const AnchorAncestor = ({
|
|
37
|
+
children
|
|
38
|
+
}: {
|
|
39
|
+
children: React.ReactNode;
|
|
40
|
+
}): React.ReactElement => (
|
|
41
|
+
<AnchorAncestorContext.Provider value={true}>
|
|
42
|
+
{children}
|
|
43
|
+
</AnchorAncestorContext.Provider>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
export default AnchorAncestor;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { forwardRef } from 'react';
|
|
4
4
|
import NextLink from 'next/link';
|
|
5
5
|
import { useLocalizeHref } from '../hooks/use-localize-href';
|
|
6
|
+
import { AnchorAncestor, useInsideAnchor } from './interactive-ancestor';
|
|
6
7
|
|
|
7
8
|
type ThemeLinkProps = React.ComponentPropsWithoutRef<typeof NextLink>;
|
|
8
9
|
|
|
@@ -28,18 +29,85 @@ type ThemeLinkProps = React.ComponentPropsWithoutRef<typeof NextLink>;
|
|
|
28
29
|
* prefetching them is paid once per click by the shopper who actually
|
|
29
30
|
* navigates. `prefetch` is still a prop: a caller that measured its own case
|
|
30
31
|
* can pass `true`.
|
|
32
|
+
*
|
|
33
|
+
* INSIDE ANOTHER ANCHOR IT IS NOT AN ANCHOR (ZERO-5188). Every link in the
|
|
34
|
+
* package comes through here, so this is the one place that can refuse to
|
|
35
|
+
* nest one. See `components/interactive-ancestor.tsx` for why a nested anchor
|
|
36
|
+
* costs the whole document.
|
|
31
37
|
*/
|
|
32
38
|
export const ThemeLink = forwardRef<HTMLAnchorElement, ThemeLinkProps>(
|
|
33
39
|
function ThemeLink({ href, prefetch = false, ...rest }, ref) {
|
|
34
40
|
const localize = useLocalizeHref();
|
|
41
|
+
const insideAnchor = useInsideAnchor();
|
|
42
|
+
|
|
43
|
+
const resolved = typeof href === 'string' ? localize(href) : href;
|
|
44
|
+
|
|
45
|
+
if (insideAnchor) {
|
|
46
|
+
const { target, rel, onClick, children, ...spanProps } = rest;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The destination survives as a real navigation, so the shopper still
|
|
50
|
+
* gets where they were going. `window.location` rather than the router:
|
|
51
|
+
* a hook here would make every block test that renders a link need an
|
|
52
|
+
* app-router context, and this path is the rare one — the OUTER link is
|
|
53
|
+
* the card a shopper aims at, this is the control nested inside it.
|
|
54
|
+
*
|
|
55
|
+
* Middle click, the context menu and crawling are the real loss. Both
|
|
56
|
+
* authored examples put something redundant here (a decorative icon
|
|
57
|
+
* with `href="#"`, a read-more pointing at the post the card already
|
|
58
|
+
* links to), so the outer anchor keeps the destination that matters.
|
|
59
|
+
*/
|
|
60
|
+
const go = () => {
|
|
61
|
+
if (typeof window === 'undefined') return;
|
|
62
|
+
|
|
63
|
+
const to = typeof resolved === 'string' ? resolved : undefined;
|
|
64
|
+
if (!to || to === '#') return;
|
|
65
|
+
|
|
66
|
+
if (target === '_blank') {
|
|
67
|
+
window.open(to, '_blank', rel || 'noopener noreferrer');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
window.location.assign(to);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<span
|
|
76
|
+
// Not `role="link"`: that would announce a link the keyboard cannot
|
|
77
|
+
// follow the way a link is followed, and it is already inside one.
|
|
78
|
+
// The element stays styleable and clickable, and the ancestor
|
|
79
|
+
// anchor still describes the destination.
|
|
80
|
+
ref={ref as unknown as React.Ref<HTMLSpanElement>}
|
|
81
|
+
data-nested-link=""
|
|
82
|
+
onClick={(event: React.MouseEvent<HTMLSpanElement>) => {
|
|
83
|
+
onClick?.(
|
|
84
|
+
event as unknown as React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
|
85
|
+
);
|
|
86
|
+
if (event.defaultPrevented) return;
|
|
87
|
+
// The ancestor anchor would otherwise navigate to its own href.
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
event.stopPropagation();
|
|
90
|
+
go();
|
|
91
|
+
}}
|
|
92
|
+
{...(spanProps as React.HTMLAttributes<HTMLSpanElement>)}
|
|
93
|
+
>
|
|
94
|
+
{children}
|
|
95
|
+
</span>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { children, ...anchorProps } = rest;
|
|
35
100
|
|
|
36
101
|
return (
|
|
37
|
-
<NextLink
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
102
|
+
<NextLink ref={ref} href={resolved} prefetch={prefetch} {...anchorProps}>
|
|
103
|
+
{/*
|
|
104
|
+
* Both sides of the rule live here, which is why there is no
|
|
105
|
+
* per-block contract to keep: this is the only element in the
|
|
106
|
+
* package that is a real anchor, so it is also the only one that has
|
|
107
|
+
* to tell its subtree one is open.
|
|
108
|
+
*/}
|
|
109
|
+
<AnchorAncestor>{children}</AnchorAncestor>
|
|
110
|
+
</NextLink>
|
|
43
111
|
);
|
|
44
112
|
}
|
|
45
113
|
);
|