mirror of
https://gitee.com/baidu/amis.git
synced 2024-11-29 18:48:45 +08:00
Merge pull request #11112 from qkiroc/feat/anchornav-sticky
feat: 锚点导航调整为sticky布局
This commit is contained in:
commit
436142d928
@ -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 {
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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});
|
||||||
}
|
}
|
||||||
|
@ -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('工作信息');
|
||||||
|
@ -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"
|
||||||
|
Loading…
Reference in New Issue
Block a user