Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat(affix): affix complete🎉 #75

Merged
merged 5 commits into from
Jul 16, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions site/sidebar.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export default [
name: 'navigation',
type: 'component', // 组件文档
children: [
{
title: 'Affix 固钉',
name: 'menu',
path: '/components/affix',
component: () => import('tdesign-web-components/affix/README.md'),
},
{
title: 'Menu 导航菜单',
name: 'menu',
Expand Down
18 changes: 18 additions & 0 deletions src/_util/dom.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import isString from 'lodash/isString';
import raf from 'raf';

import { ScrollContainer, ScrollContainerElement } from '../common';
import { easeInOutCubic, EasingFunction } from './easing';
// 用于判断是否可使用 dom
export const canUseDocument = !!(typeof window !== 'undefined' && window.document && window.document.createElement);

/**
* 获取滚动容器
* 因为 document 不存在 scroll 等属性, 因此排除 document
* window | HTMLElement
* @param {ScrollContainerElement} [container='body']
* @returns {ScrollContainer}
*/
export const getScrollContainer = (container: ScrollContainer = 'body'): ScrollContainerElement => {
if (isString(container)) {
return container ? (document.querySelector(container) as HTMLElement) : window;
}
if (typeof container === 'function') {
return container();
}
return container || window;
};

// 获取 css vars
export const getCssVarsValue = (name: string, element?: HTMLElement) => {
if (!canUseDocument) return;
Expand Down
35 changes: 35 additions & 0 deletions src/affix/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
title: Affix 固钉
description: 在指定的范围内,将元素固定不动
isComponent: true
usage: { title: '', description: '' }
spline: base
---

### 基础固钉
适用于页面结构简单的场景,默认容器是 `body` 。

{{ base }}

### 指定挂载的容器

适用于较为复杂的场景,元素固定位置会受容器位置的影响。

{{ container }}


## API

### Affix Props

名称 | 类型 | 默认值 | 说明 | 必传
-- | -- | -- | -- | --
className | String | - | 类名 | N
style | Object | - | 样式,TS 类型:`React.CSSProperties` | N
children | TNode | - | 内容,同 content。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/common.ts) | N
container | String / Function | () => (() => window) | 指定滚动的容器。数据类型为 String 时,会被当作选择器处理,进行节点查询。示例:'body' 或 () => document.body。TS 类型:`ScrollContainer`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/common.ts) | N
content | TNode | - | 内容。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/common.ts) | N
offsetBottom | Number | 0 | 距离容器顶部达到指定距离后触发固定 | N
offsetTop | Number | 0 | 距离容器底部达到指定距离后触发固定 | N
zIndex | Number | - | 固钉定位层级,样式默认为 500 | N
onFixedChange | Function | | TS 类型: `(affixed: boolean, context: { top: number }) => void`<br/>固定状态发生变化时触发 | N
21 changes: 21 additions & 0 deletions src/affix/_example/base.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'tdesign-web-components/affix';
import 'tdesign-web-components/button';

import { bind, Component, signal } from 'omi';

export default class Affix extends Component {
top = signal(150);

@bind
handleClick() {
this.top.value += 10;
}

render() {
return (
<t-affix offsetTop={this.top.value} offsetBottom={10}>
<t-button onClick={this.handleClick}>固钉</t-button>
</t-affix>
);
}
}
71 changes: 71 additions & 0 deletions src/affix/_example/container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import 'tdesign-web-components/affix';
import 'tdesign-web-components/button';

import { bind, Component, createRef, signal } from 'omi';

export default class Container extends Component {
container = signal(null);

affixRef = createRef<any>();

affixed = signal(false);

@bind
handleFixedChange(affixed, { top }) {
console.log('top', top);
this.affixed.value = affixed;
}

uninstall(): void {
if (this.affixRef.current) {
const { handleScroll } = this.affixRef.current;
window.removeEventListener('resize', handleScroll);
}
}

install(): void {
if (this.affixRef.current) {
const { handleScroll } = this.affixRef.current;
window.addEventListener('resize', handleScroll);
}
}

backgroundStyle = {
height: '1500px',
paddingTop: '700px',
backgroundColor: '#eee',
backgroundImage:
'linear-gradient(45deg,#bbb 25%,transparent 0),linear-gradient(45deg,transparent 75%,#bbb 0),linear-gradient(45deg,#bbb 25%,transparent 0),linear-gradient(45deg,transparent 75%,#bbb 0)',
backgroundSize: '30px 30px',
backgroundPosition: '0 0,15px 15px,15px 15px,0 0',
};

render() {
return (
<div
style={{
border: '1px solid var(--component-stroke)',
borderRadius: '3px',
height: '400px',
overflowX: 'hidden',
overflowY: 'auto',
overscrollBehavior: 'none',
}}
ref={(ref) => (this.container.value = ref)}
>
<div style={this.backgroundStyle}>
<t-affix
offsetTop={50}
offsetBottom={50}
container={() => this.container.value}
zIndex={5}
onFixedChange={this.handleFixedChange}
ref={this.affixRef}
>
<t-button>affixed: {`${this.affixed.value}`}</t-button>
</t-affix>
</div>
</div>
);
}
}
133 changes: 133 additions & 0 deletions src/affix/affix.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { isFunction } from 'lodash';
import { Component, createRef, tag } from 'omi';

import { getClassPrefix } from '../_util/classname';
import { getScrollContainer } from '../_util/dom';
import { ScrollContainerElement, StyledProps } from '../common';
import { AffixRef, TdAffixProps } from './type';

export interface AffixProps extends TdAffixProps, StyledProps, AffixRef {}

@tag('t-affix')
export default class Affix extends Component<AffixProps> {
static defaultProps = { container: () => window, offsetBottom: 0, offsetTop: 0 };

static propsType = {
children: Function || Object || String || Number,
container: Function || Object || String || Number,
content: Function || Object || String || Number,
offsetBottom: Number,
offsetTop: Number,
zIndex: Number,
onFixedChange: Function,
};

innerOffsetTop = 0;

inneroffsetBottom = 0;

innerZIndex = 0;

affixWrapRef = createRef<HTMLDivElement>();

affixRef = createRef<HTMLDivElement>();

scrollContainer: ScrollContainerElement = null;

placeholderEL: HTMLDivElement = null;

ticking = false;

handleScroll = () => {
if (this.ticking) return;
window.requestAnimationFrame(() => {
let fixedTop: number | false;
const wrapToTop = this.affixWrapRef.current.getBoundingClientRect().top || 0;
const wrapWidth = this.affixWrapRef.current.getBoundingClientRect().width || 0;
const wrapHeight = this.affixWrapRef.current.getBoundingClientRect().height || 0;
let containerToTop = 0;
if (this.scrollContainer instanceof HTMLElement) {
containerToTop = this.scrollContainer.getBoundingClientRect().top;
}
const calcTop = wrapToTop - containerToTop;
const containerHeight =
this.scrollContainer[this.scrollContainer instanceof Window ? 'innerHeight' : 'clientHeight'] - wrapHeight;
const calcBottom = containerToTop + containerHeight - (this.inneroffsetBottom ?? 0);
if (this.innerOffsetTop !== undefined && calcTop <= this.innerOffsetTop) {
fixedTop = containerToTop + this.innerOffsetTop;
} else if (this.inneroffsetBottom !== undefined && wrapToTop >= calcBottom) {
fixedTop = calcBottom;
} else {
fixedTop = false;
}
if (!this.affixRef) return;
const affixed = fixedTop !== false;
let placeholderStatus = this.affixWrapRef.current.contains(this.placeholderEL);
const prePlaceholderStatus = placeholderStatus;
if (affixed) {
this.affixRef.current.className = `${getClassPrefix()}-affix`;
this.affixRef.current.style.top = `${fixedTop}px`;
this.affixRef.current.style.width = `${wrapWidth}px`;
this.affixRef.current.style.height = `${wrapHeight}px`;
if (this.innerZIndex) {
this.affixRef.current.style.zIndex = `${this.innerZIndex}`;
}
if (!placeholderStatus) {
this.placeholderEL.style.width = `${wrapWidth}px`;
this.placeholderEL.style.height = `${wrapHeight}px`;
this.affixWrapRef.current.appendChild(this.placeholderEL);
placeholderStatus = true;
}
} else {
this.affixRef.current.removeAttribute('class');
this.affixRef.current.removeAttribute('style');
if (placeholderStatus) {
this.placeholderEL.remove();
placeholderStatus = false;
}
}
if (prePlaceholderStatus !== placeholderStatus && isFunction(this.props.onFixedChange)) {
this.props.onFixedChange(affixed, { top: +fixedTop });
}
this.ticking = false;
});
this.ticking = true;
};

receiveProps(newProps: AffixProps) {
const { offsetBottom, offsetTop, zIndex } = newProps;
this.innerOffsetTop = offsetTop;
this.inneroffsetBottom = offsetBottom;
this.innerZIndex = zIndex;
this.handleScroll();
return true;
}

installed() {
const { offsetBottom, offsetTop, zIndex, container } = this.props;
this.innerOffsetTop = offsetTop;
this.inneroffsetBottom = offsetBottom;
this.innerZIndex = zIndex;
this.placeholderEL = document.createElement('div');
this.scrollContainer = getScrollContainer(container);
if (this.scrollContainer) {
this.handleScroll();
this.scrollContainer.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.handleScroll);
}
}

uninstalled() {
this.scrollContainer.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleScroll);
}

render() {
const { children, content, style } = this.props;
return (
<div ref={this.affixWrapRef} className={this.className} style={style}>
<div ref={this.affixRef}>{children || content}</div>
</div>
);
}
}
9 changes: 9 additions & 0 deletions src/affix/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import './style/index.js';

import _Affix from './affix';

export type { AffixProps } from './affix';
export const Affix = _Affix;
export default Affix;

export * from './type';
10 changes: 10 additions & 0 deletions src/affix/style/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { css, globalCSS } from 'omi';

// 为了做主题切换
import styles from '../../_common/style/web/components/affix/_index.less';

export const styleSheet = css`
${styles}
`;

globalCSS(styleSheet);
39 changes: 39 additions & 0 deletions src/affix/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ScrollContainer, TNode } from '../common';

export interface TdAffixProps {
/**
* 内容,同 content
*/
children?: TNode;
/**
* 指定滚动的容器。数据类型为 String 时,会被当作选择器处理,进行节点查询。示例:'body' 或 () => document.body
* @default () => window
*/
container?: ScrollContainer;
/**
* 内容
*/
content?: TNode;
/**
* 距离容器底部达到指定距离后触发固定
* @default 0
*/
offsetBottom?: number;
/**
* 距离容器顶部达到指定距离后触发固定
* @default 0
*/
offsetTop?: number;
/**
* 固钉定位层级,样式默认为 500
*/
zIndex?: number;
/**
* 固定状态发生变化时触发
*/
onFixedChange?: (affixed: boolean, context: { top: number }) => void;
}

export interface AffixRef {
handleScroll: () => void;
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './affix';
export * from './avatar';
export * from './button';
export * from './collapse';
Expand Down
Loading