Merge pull request #11112 from qkiroc/feat/anchornav-sticky

feat: 锚点导航调整为sticky布局
This commit is contained in:
Allen 2024-10-29 10:12:50 +08:00 committed by GitHub
commit 436142d928
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 97 additions and 91 deletions

View File

@ -19,6 +19,7 @@
position: relative; position: relative;
width: 100%; width: 100%;
margin: 20px 0; margin: 20px 0;
--affix-offset-top: 70px;
} }
.doc-play-ground > .visibility-sensor { .doc-play-ground > .visibility-sensor {

View File

@ -1,6 +1,6 @@
.#{$ns}AnchorNav { .#{$ns}AnchorNav {
display: flex; display: flex;
height: px2rem(400px); min-height: px2rem(400px);
&--vertical { &--vertical {
.#{$ns}AnchorNav-link-wrap { .#{$ns}AnchorNav-link-wrap {
@ -8,11 +8,17 @@
padding: 0; padding: 0;
width: var(--Tabs--vertical-width); width: var(--Tabs--vertical-width);
border-left: var(--AnchorNav-links-container-borderRight); border-left: var(--AnchorNav-links-container-borderRight);
padding-bottom: px2rem(60px); padding-bottom: px2rem(20px);
position: sticky;
top: var(--affix-offset-top);
height: fit-content;
max-height: 100%;
overflow-y: scroll;
> .#{$ns}AnchorNav-link { > .#{$ns}AnchorNav-link {
position: relative; position: relative;
display: block; display: block;
&.#{$ns}AnchorNav-link-child { &.#{$ns}AnchorNav-link-child {
margin-left: px2rem(16px); margin-left: px2rem(16px);
} }
@ -71,6 +77,10 @@
padding: 0px; padding: 0px;
border-bottom: var(--Tabs-borderWidth) solid var(--Tabs-borderColor); border-bottom: var(--Tabs-borderWidth) solid var(--Tabs-borderColor);
list-style: none; list-style: none;
position: sticky;
top: var(--affix-offset-top);
z-index: 1;
background-color: var(--background);
> .#{$ns}AnchorNav-link { > .#{$ns}AnchorNav-link {
margin-bottom: calc(var(--Tabs-borderWidth) * -1); margin-bottom: calc(var(--Tabs-borderWidth) * -1);
@ -123,6 +133,12 @@
} }
} }
} }
.#{$ns}AnchorNav-section-wrap {
> .#{$ns}AnchorNav-section {
scroll-margin: calc(var(--affix-offset-top) + 50px);
}
}
} }
&-section-wrap { &-section-wrap {
@ -136,6 +152,7 @@
> .#{$ns}AnchorNav-section { > .#{$ns}AnchorNav-section {
display: block; display: block;
padding: var(--gap-base); padding: var(--gap-base);
scroll-margin: var(--affix-offset-top);
} }
} }
} }

View File

@ -10,7 +10,7 @@ import {ThemeProps, themeable} from 'amis-core';
import {autobind} from 'amis-core'; import {autobind} from 'amis-core';
import {uncontrollable} from 'amis-core'; import {uncontrollable} from 'amis-core';
import find from 'lodash/find'; import find from 'lodash/find';
import type {PlainObject, Schema} from 'amis-core'; import type {Schema} from 'amis-core';
export interface AnchorNavSectionProps extends ThemeProps { export interface AnchorNavSectionProps extends ThemeProps {
title?: string; // 标题 title?: string; // 标题
@ -26,10 +26,14 @@ class AnchorNavSectionComponent extends React.PureComponent<AnchorNavSectionProp
contentRef = (ref: any) => (this.contentDom = ref); contentRef = (ref: any) => (this.contentDom = ref);
render() { render() {
const {classnames: cx, children, className} = this.props; const {classnames: cx, children, className, name} = this.props;
return ( return (
<div ref={this.contentRef} className={cx('AnchorNav-section', className)}> <div
ref={this.contentRef}
className={cx('AnchorNav-section', className)}
id={name + ''}
>
{children} {children}
</div> </div>
); );
@ -52,17 +56,7 @@ export interface AnchorNavProps extends ThemeProps {
children?: React.ReactNode | Array<React.ReactNode>; children?: React.ReactNode | Array<React.ReactNode>;
} }
interface SectionOffset { export class AnchorNav extends React.Component<AnchorNavProps> {
key: string | number;
offsetTop: number;
}
export interface AnchorNavState {
offsetArr: SectionOffset[]; // 记录每个段落的offsetTop
fromSelect: boolean; // 标识滚动触发来源
}
export class AnchorNav extends React.Component<AnchorNavProps, AnchorNavState> {
static defaultProps: Pick< static defaultProps: Pick<
AnchorNavProps, AnchorNavProps,
'linkClassName' | 'sectionClassName' | 'direction' 'linkClassName' | 'sectionClassName' | 'direction'
@ -76,97 +70,66 @@ export class AnchorNav extends React.Component<AnchorNavProps, AnchorNavState> {
contentDom: React.RefObject<HTMLDivElement> = React.createRef(); contentDom: React.RefObject<HTMLDivElement> = React.createRef();
// 后代节点观察器 // 后代节点观察器
observer: MutationObserver; observer: IntersectionObserver;
sections: { sections: {
key: string | number; key: string | number;
element: HTMLDivElement; element: HTMLDivElement;
isIntersecting?: boolean;
}[] = []; }[] = [];
componentDidMount() { fromSelect: boolean = false;
// 初始化滚动标识 fromSelectTimer: any;
this.setState({fromSelect: false});
const sectionRootDom = componentDidMount() {
this.contentDom && (this.contentDom.current as HTMLElement); this.observer = new IntersectionObserver(this.scrollToNav);
sectionRootDom.addEventListener('scroll', this.scrollToNav); this.sections.forEach(item => {
this.observer.observe(item.element);
});
if (this.props.active) {
this.scrollToSection(this.props.active);
}
} }
componentWillUnmount() { componentWillUnmount() {
if (this.contentDom && this.contentDom.current) { this.observer.disconnect();
this.contentDom.current.removeEventListener('scroll', this.scrollToNav);
}
} }
@autobind @autobind
scrollToNav(e: Event) { scrollToNav(entries: IntersectionObserverEntry[]) {
if (this.state.fromSelect) { entries.forEach(entry => {
return; const key = entry.target.id;
} const currentSection = this.sections.find(item => item.key === key);
if (currentSection) {
// 获取滚动的scrollTop currentSection.isIntersecting = entry.isIntersecting;
const {scrollTop, scrollHeight, clientHeight} = e.target as HTMLElement; }
});
// 是否到达最底部,以防最后一个因为高度不够无法高亮 // 找到第一个可见的区域
const isReachBottom = scrollTop + clientHeight >= scrollHeight; const firstIntersectingSection = this.sections.find(
item => item.isIntersecting
// 判断scrollTop所在区域 );
const firstSection = this.sections[0]; if (!this.fromSelect) {
const lastSection = this.sections[this.sections.length - 1]; firstIntersectingSection && this.fireSelect(firstIntersectingSection.key);
// 首层偏移
const offset = scrollTop + firstSection.element.offsetTop;
// 首层
if (offset <= firstSection.element.offsetTop) {
this.fireSelect(firstSection.key);
}
// 最后一层
else if (isReachBottom || offset >= lastSection.element.offsetTop) {
this.fireSelect(lastSection.key);
} else { } else {
// 段落区间判断 // 滚动结束后重置fromSelect状态
this.sections.forEach((item, index) => { if (this.fromSelectTimer) {
if ( clearTimeout(this.fromSelectTimer);
offset >= item.element.offsetTop && }
offset < this.sections[index + 1].element.offsetTop this.fromSelectTimer = setTimeout(() => {
) { this.fromSelect = false;
this.fireSelect(item.key); }, 300);
}
});
} }
} }
scrollToSection(key: string | number) { scrollToSection(key: string | number) {
// 获取指定段落的offsettop this.fromSelect = true;
const node = find(this.sections, item => item.key === key); const node = find(this.sections, item => item.key === key)?.element;
const sectionRootDom = node?.scrollIntoView?.({behavior: 'smooth'});
this.contentDom && (this.contentDom.current as HTMLElement);
// 滚动到指定段落
node &&
(sectionRootDom.scrollTop =
node.element.offsetTop - this.sections[0].element.offsetTop);
} }
handleSelect(key: string | number) { handleSelect(key: string | number) {
// 标记滚动来自导航选择
this.setState({fromSelect: true});
// 滚动到对应段落
this.scrollToSection(key); this.scrollToSection(key);
const sectionRootDom = this.fireSelect(key);
this.contentDom && (this.contentDom.current as HTMLElement);
// 如果已经滚到底就不去更新导航选中了
if (
sectionRootDom.scrollHeight - sectionRootDom.scrollTop <
sectionRootDom.clientHeight
) {
// fire event
this.fireSelect(key);
}
// 取消标记
this.setState({fromSelect: false});
} }
fireSelect(key: string | number) { fireSelect(key: string | number) {
@ -215,7 +178,7 @@ export class AnchorNav extends React.Component<AnchorNavProps, AnchorNavState> {
classnames, classnames,
active, active,
ref: (props: any) => { ref: (props: any) => {
if (props && !this.sections.find(item => item.key === key)) { if (props && !this.sections.find(item => item.key === name)) {
// 收集每个段落的真实dom节点 // 收集每个段落的真实dom节点
this.sections.push({key: name, element: props.ref.contentDom}); this.sections.push({key: name, element: props.ref.contentDom});
} }

View File

@ -169,7 +169,7 @@ test('Renderer:anchorNav with horizontal', () => {
}); });
// 3.默认定位到某个区域 // 3.默认定位到某个区域
test('Renderer:anchorNav with active', async () => { test('Renderer:anchorNav with active by index', async () => {
const {container} = render( const {container} = render(
amisRender( amisRender(
{ {
@ -180,7 +180,27 @@ test('Renderer:anchorNav with active', async () => {
className: 'one', className: 'one',
active: 2, active: 2,
links: defaultLinks links: defaultLinks
}, }
]
},
{},
makeEnv({})
)
);
await wait(500);
expect(
container.querySelector('.one .cxd-AnchorNav-link.is-active')
).toHaveTextContent('兴趣爱好');
});
test('Renderer:anchorNav with active by href', async () => {
const {container} = render(
amisRender(
{
type: 'page',
body: [
{ {
type: 'anchor-nav', type: 'anchor-nav',
className: 'two', className: 'two',
@ -199,9 +219,6 @@ test('Renderer:anchorNav with active', async () => {
await wait(500); await wait(500);
expect(
container.querySelector('.one .cxd-AnchorNav-link.is-active')
).toHaveTextContent('兴趣爱好');
expect( expect(
container.querySelector('.two .cxd-AnchorNav-link.is-active') container.querySelector('.two .cxd-AnchorNav-link.is-active')
).toHaveTextContent('工作信息'); ).toHaveTextContent('工作信息');

View File

@ -55,6 +55,7 @@ exports[`Renderer:anchorNav 1`] = `
> >
<div <div
class="cxd-AnchorNav-section" class="cxd-AnchorNav-section"
id="0"
> >
<div <div
class="cxd-Panel cxd-Panel--default cxd-Panel--form" class="cxd-Panel cxd-Panel--default cxd-Panel--form"
@ -179,6 +180,7 @@ exports[`Renderer:anchorNav 1`] = `
</div> </div>
<div <div
class="cxd-AnchorNav-section" class="cxd-AnchorNav-section"
id="1"
> >
<div <div
class="cxd-Panel cxd-Panel--default cxd-Panel--form" class="cxd-Panel cxd-Panel--default cxd-Panel--form"
@ -303,6 +305,7 @@ exports[`Renderer:anchorNav 1`] = `
</div> </div>
<div <div
class="cxd-AnchorNav-section" class="cxd-AnchorNav-section"
id="2"
> >
<div <div
class="cxd-Panel cxd-Panel--default cxd-Panel--form" class="cxd-Panel cxd-Panel--default cxd-Panel--form"
@ -642,6 +645,7 @@ exports[`Renderer:anchorNav with children 1`] = `
> >
<div <div
class="cxd-AnchorNav-section" class="cxd-AnchorNav-section"
id="0"
> >
<div <div
class="cxd-Panel cxd-Panel--default cxd-Panel--form" class="cxd-Panel cxd-Panel--default cxd-Panel--form"
@ -766,6 +770,7 @@ exports[`Renderer:anchorNav with children 1`] = `
</div> </div>
<div <div
class="cxd-AnchorNav-section" class="cxd-AnchorNav-section"
id="0-0"
> >
<div <div
class="cxd-Panel cxd-Panel--default cxd-Panel--form" class="cxd-Panel cxd-Panel--default cxd-Panel--form"
@ -890,6 +895,7 @@ exports[`Renderer:anchorNav with children 1`] = `
</div> </div>
<div <div
class="cxd-AnchorNav-section" class="cxd-AnchorNav-section"
id="0-1"
> >
<div <div
class="cxd-Panel cxd-Panel--default cxd-Panel--form" class="cxd-Panel cxd-Panel--default cxd-Panel--form"
@ -1014,6 +1020,7 @@ exports[`Renderer:anchorNav with children 1`] = `
</div> </div>
<div <div
class="cxd-AnchorNav-section" class="cxd-AnchorNav-section"
id="1"
> >
<div <div
class="cxd-Panel cxd-Panel--default cxd-Panel--form" class="cxd-Panel cxd-Panel--default cxd-Panel--form"
@ -1182,6 +1189,7 @@ exports[`Renderer:anchorNav with horizontal 1`] = `
> >
<div <div
class="cxd-AnchorNav-section" class="cxd-AnchorNav-section"
id="0"
> >
<div <div
class="cxd-Panel cxd-Panel--default cxd-Panel--form" class="cxd-Panel cxd-Panel--default cxd-Panel--form"