Skip to content

Commit a4297af

Browse files
johannes-weberJohannes Weber
and
Johannes Weber
authored
chore: Ensure AnchorNavigation lists are marked up properly (#3342)
Co-authored-by: Johannes Weber <[email protected]>
1 parent 4ab26f0 commit a4297af

File tree

4 files changed

+224
-59
lines changed

4 files changed

+224
-59
lines changed

src/anchor-navigation/__tests__/anchor-navigation.test.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,62 @@ describe('AnchorNavigation', () => {
132132
detail: { text: 'Section 2', href: '#section2', level: 1 },
133133
});
134134
});
135+
136+
describe('nested anchors', () => {
137+
const getParentTagChainUntilLastOL = (element: HTMLElement): string[] => {
138+
const parentChain: string[] = [];
139+
let currentElement = element;
140+
141+
while (currentElement.parentElement) {
142+
parentChain.push(currentElement.parentElement.tagName);
143+
currentElement = currentElement.parentElement;
144+
145+
// Stop when reaching an element whose parent is not OL or LI
146+
if (currentElement.parentElement && !['OL', 'LI'].includes(currentElement.parentElement.tagName)) {
147+
break;
148+
}
149+
}
150+
151+
return parentChain;
152+
};
153+
154+
it('renders nested levels in a sub-list', () => {
155+
const wrapper = renderAnchorNavigation({
156+
anchors: [
157+
{ text: 'Section 1', href: '#section1', level: 1 },
158+
{ text: 'Section 1.1', href: '#section1.1', level: 2 },
159+
{ text: 'Section 1.1.1', href: '#section1.1.1', level: 3 },
160+
{ text: 'Section 1.1.1.1', href: '#section1.1.1.1', level: 4 },
161+
{ text: 'Section 1.2', href: '#section1.2', level: 2 },
162+
{ text: 'Section 2', href: '#section2', level: 1 },
163+
{ text: 'Section 3', href: '#section3', level: 1 },
164+
{ text: 'Section 3.1', href: '#section3.1', level: 2 },
165+
{ text: 'Section 4', href: '#section4', level: 1 },
166+
{ text: 'Section 4.1.1.1', href: '#section4.1.1.1', level: 4 },
167+
],
168+
});
169+
170+
const olElements = wrapper.findAnchorNavigation()!.getElement().querySelectorAll('ol');
171+
expect(olElements).toHaveLength(6);
172+
173+
const expectedNestings: { [key: number]: string[] } = {
174+
1: ['OL'],
175+
2: ['OL', 'LI', 'OL'],
176+
3: ['OL', 'LI', 'OL', 'LI', 'OL'],
177+
4: ['OL', 'LI', 'OL', 'LI', 'OL', 'LI', 'OL'],
178+
5: ['OL', 'LI', 'OL'],
179+
6: ['OL'],
180+
7: ['OL'],
181+
8: ['OL', 'LI', 'OL'],
182+
9: ['OL'],
183+
10: ['OL', 'LI', 'OL'],
184+
};
185+
186+
Object.keys(expectedNestings).forEach(i => {
187+
const index = Number(i);
188+
const tagChain = getParentTagChainUntilLastOL(wrapper.findAnchorByIndex(index)!.getElement()!);
189+
expect(tagChain).toEqual(expectedNestings[index]);
190+
});
191+
});
192+
});
135193
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React, { useCallback } from 'react';
4+
import clsx from 'clsx';
5+
6+
import { isPlainLeftClick } from '../../internal/events';
7+
import { checkSafeUrl } from '../../internal/utils/check-safe-url';
8+
import { AnchorNavigationProps } from '../interfaces';
9+
10+
import styles from '../styles.css.js';
11+
import testUtilsStyles from '../test-classes/styles.css.js';
12+
13+
interface AnchorItemProps {
14+
anchor: AnchorNavigationProps.Anchor;
15+
onFollow: (anchor: AnchorNavigationProps.Anchor, event: React.SyntheticEvent | Event) => void;
16+
isActive: boolean;
17+
index: number;
18+
children: React.ReactNode;
19+
}
20+
21+
export const AnchorItem = ({ anchor, onFollow, isActive, index, children }: AnchorItemProps) => {
22+
checkSafeUrl('AnchorNavigation', anchor.href);
23+
24+
const onClick = useCallback(
25+
(event: React.MouseEvent) => {
26+
if (isPlainLeftClick(event)) {
27+
onFollow(anchor, event);
28+
}
29+
},
30+
[onFollow, anchor]
31+
);
32+
33+
const activeItemClasses = clsx(styles['anchor-item--active'], testUtilsStyles['anchor-item--active']);
34+
35+
return (
36+
<li data-itemid={`anchor-item-${index + 1}`} className={clsx(styles['anchor-item'], isActive && activeItemClasses)}>
37+
<a
38+
onClick={onClick}
39+
className={clsx(
40+
styles['anchor-link'],
41+
testUtilsStyles['anchor-link'],
42+
isActive && styles['anchor-link--active']
43+
)}
44+
{...(isActive ? { 'aria-current': true } : {})}
45+
href={anchor.href}
46+
>
47+
<span
48+
className={clsx(styles['anchor-link-text'], testUtilsStyles['anchor-link-text'])}
49+
style={{ paddingInlineStart: `${anchor.level * 16 + 2}px` }}
50+
>
51+
{anchor.text}
52+
</span>
53+
{anchor.info && (
54+
<span className={clsx(styles['anchor-link-info'], testUtilsStyles['anchor-link-info'])}>{anchor.info}</span>
55+
)}
56+
</a>
57+
{children}
58+
</li>
59+
);
60+
};

src/anchor-navigation/internal.tsx

Lines changed: 5 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import React, { useCallback, useEffect, useMemo } from 'react';
44
import clsx from 'clsx';
55

66
import { getBaseProps } from '../internal/base-component/index.js';
7-
import { fireCancelableEvent, fireNonCancelableEvent, isPlainLeftClick } from '../internal/events/index';
7+
import { fireCancelableEvent, fireNonCancelableEvent } from '../internal/events/index';
88
import { InternalBaseComponentProps } from '../internal/hooks/use-base-component/index.js';
9-
import { checkSafeUrl } from '../internal/utils/check-safe-url';
109
import { AnchorNavigationProps } from './interfaces';
1110
import useScrollSpy from './use-scroll-spy.js';
11+
import { renderNestedAnchors } from './utils';
1212

1313
import styles from './styles.css.js';
1414
import testUtilsStyles from './test-classes/styles.css.js';
@@ -55,65 +55,11 @@ export default function InternalAnchorNavigation({
5555
className={clsx(baseProps.className, styles.root, testUtilsStyles.root)}
5656
>
5757
<ol className={clsx(styles['anchor-list'], testUtilsStyles['anchor-list'])}>
58-
{anchors.map((anchor, index) => {
59-
return (
60-
<Anchor
61-
onFollow={onFollowHandler}
62-
isActive={anchor.href === currentActiveHref}
63-
key={index}
64-
index={index}
65-
anchor={anchor}
66-
/>
67-
);
58+
{renderNestedAnchors(anchors, {
59+
onFollowHandler,
60+
currentActiveHref,
6861
})}
6962
</ol>
7063
</nav>
7164
);
7265
}
73-
74-
interface AnchorProps {
75-
anchor: AnchorNavigationProps.Anchor;
76-
onFollow: (anchor: AnchorNavigationProps.Anchor, event: React.SyntheticEvent | Event) => void;
77-
isActive: boolean;
78-
index: number;
79-
}
80-
81-
const Anchor = ({ anchor, onFollow, isActive, index }: AnchorProps) => {
82-
checkSafeUrl('AnchorNavigation', anchor.href);
83-
84-
const onClick = useCallback(
85-
(event: React.MouseEvent) => {
86-
if (isPlainLeftClick(event)) {
87-
onFollow(anchor, event);
88-
}
89-
},
90-
[onFollow, anchor]
91-
);
92-
93-
const activeItemClasses = clsx(styles['anchor-item--active'], testUtilsStyles['anchor-item--active']);
94-
95-
return (
96-
<li data-itemid={`anchor-item-${index + 1}`} className={clsx(styles['anchor-item'], isActive && activeItemClasses)}>
97-
<a
98-
onClick={onClick}
99-
className={clsx(
100-
styles['anchor-link'],
101-
testUtilsStyles['anchor-link'],
102-
isActive && styles['anchor-link--active']
103-
)}
104-
{...(isActive ? { 'aria-current': true } : {})}
105-
href={anchor.href}
106-
>
107-
<span
108-
className={clsx(styles['anchor-link-text'], testUtilsStyles['anchor-link-text'])}
109-
style={{ paddingInlineStart: `${anchor.level * 16 + 2}px` }}
110-
>
111-
{anchor.text}
112-
</span>
113-
{anchor.info && (
114-
<span className={clsx(styles['anchor-link-info'], testUtilsStyles['anchor-link-info'])}>{anchor.info}</span>
115-
)}
116-
</a>
117-
</li>
118-
);
119-
};

src/anchor-navigation/utils.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React from 'react';
4+
import clsx from 'clsx';
5+
6+
import { AnchorItem } from './anchor-item';
7+
import { AnchorNavigationProps } from './interfaces';
8+
9+
import styles from './styles.css.js';
10+
import testUtilsStyles from './test-classes/styles.css.js';
11+
12+
interface AnchorRenderQueueItem {
13+
items: AnchorNavigationProps.Anchor[];
14+
parentList: React.ReactNode[];
15+
startIndex: number;
16+
}
17+
18+
const collectChildItems = (
19+
items: AnchorNavigationProps.Anchor[],
20+
currentIndex: number,
21+
currentLevel: number
22+
): AnchorNavigationProps.Anchor[] => {
23+
const childItems: AnchorNavigationProps.Anchor[] = [];
24+
let nextIndex = currentIndex + 1;
25+
26+
while (nextIndex < items.length && items[nextIndex].level > currentLevel) {
27+
childItems.push(items[nextIndex]);
28+
nextIndex++;
29+
}
30+
31+
return childItems;
32+
};
33+
34+
interface RenderContext {
35+
onFollowHandler: (anchor: AnchorNavigationProps.Anchor, sourceEvent: React.SyntheticEvent | Event) => void;
36+
currentActiveHref: string | undefined;
37+
}
38+
39+
const createAnchorItem = (
40+
currentItem: AnchorNavigationProps.Anchor,
41+
index: number,
42+
childItems: AnchorNavigationProps.Anchor[],
43+
renderQueue: AnchorRenderQueueItem[],
44+
context: RenderContext
45+
) => {
46+
const childList: React.ReactNode[] = [];
47+
const hasChildren = childItems.length > 0;
48+
const olClassNAme = clsx(styles['anchor-list'], testUtilsStyles['anchor-list']);
49+
50+
if (hasChildren) {
51+
renderQueue.push({
52+
items: childItems,
53+
parentList: childList,
54+
startIndex: index + 1,
55+
});
56+
}
57+
58+
return (
59+
<AnchorItem
60+
onFollow={context.onFollowHandler}
61+
isActive={currentItem.href === context.currentActiveHref}
62+
key={index}
63+
index={index}
64+
anchor={currentItem}
65+
>
66+
{hasChildren && <ol className={olClassNAme}>{childList}</ol>}
67+
</AnchorItem>
68+
);
69+
};
70+
71+
const processQueueItem = (
72+
items: AnchorNavigationProps.Anchor[],
73+
startIndex: number,
74+
parentList: React.ReactNode[],
75+
renderQueue: AnchorRenderQueueItem[],
76+
context: RenderContext
77+
) => {
78+
let currentIndex = 0;
79+
while (currentIndex < items.length) {
80+
const currentItem = items[currentIndex];
81+
const childItems = collectChildItems(items, currentIndex, currentItem.level);
82+
83+
parentList.push(createAnchorItem(currentItem, startIndex + currentIndex, childItems, renderQueue, context));
84+
currentIndex += childItems.length + 1;
85+
}
86+
};
87+
88+
// Perform a queue-based breadth-first traversal that groups child items under their parents based on level hierarchy.
89+
export const renderNestedAnchors = (items: AnchorNavigationProps.Anchor[], context: RenderContext): React.ReactNode => {
90+
const rootList: React.ReactNode[] = [];
91+
const renderQueue: AnchorRenderQueueItem[] = [];
92+
93+
renderQueue.push({ items, parentList: rootList, startIndex: 0 });
94+
95+
while (renderQueue.length > 0) {
96+
const currentItem = renderQueue.shift()!;
97+
processQueueItem(currentItem.items, currentItem.startIndex, currentItem.parentList, renderQueue, context);
98+
}
99+
100+
return rootList;
101+
};

0 commit comments

Comments
 (0)