Skip to content

Commit

Permalink
✨ feat(ims-view-pc): add sticky
Browse files Browse the repository at this point in the history
  • Loading branch information
eternallycyf committed Feb 22, 2024
1 parent 85f6e7e commit 893eb18
Show file tree
Hide file tree
Showing 18 changed files with 709 additions and 2 deletions.
5 changes: 3 additions & 2 deletions packages/ims-view-pc/src/components/AudioPlayer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,11 +282,12 @@ const InternalAudioPlayer: React.ForwardRefRenderFunction<unknown, AudioPlayerPr
open={rateOpen}
onOpenChange={onRateVisibleChange}
getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement}
content={rateRange.map((rateItem) => (
content={rateRange.map((rateItem, index) => (
<p
key={index}
className="change-audio-rate-item"
style={{ '--colorPrimaryHover': variables?.colorPrimaryHover }}
key={`rate-${rateItem}`}
// key={`rate-${rateItem}`}
onClick={() => {
controlAudio('changeRate', rateItem);
}}
Expand Down
136 changes: 136 additions & 0 deletions packages/ims-view-pc/src/components/Sticky/Sticky.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import React, { CSSProperties, useCallback, useMemo, useRef } from 'react';
import useEventListener from './hooks/useEventListener';
import { getRect } from './hooks/useRect';
import useScrollParent from './hooks/useScrollParent';
import useSetState from './hooks/useSetState';
import useUpdateEffect from './hooks/useUpdateEffect';
import useVisibilityChange from './hooks/useVisibilityChange';
import './index.less';

import {
extend,
getScrollTop,
getZIndexStyle,
isHidden,
mergeProps,
unitToPx,
} from './hooks/utils';
import { StickyProps } from './interface';

const Sticky: React.FC<StickyProps> = (p) => {
const props = mergeProps(p, {
offsetTop: 0,
offsetBottom: 0,
position: 'top',
});
const [state, updateState] = useSetState({
fixed: false,
width: 0, // root width
height: 0, // root height
transform: 0,
});

const root = useRef<HTMLDivElement>(null);
const scrollParent = useScrollParent(root);

const offset = useMemo<number>(
() => unitToPx(props.position === 'top' ? props.offsetTop : props.offsetBottom),
[props.position, props.offsetTop, props.offsetBottom],
);

const rootStyle = useMemo<CSSProperties | undefined>(() => {
const { fixed, height, width } = state;
if (fixed) {
return {
width: `${width}px`,
height: `${height}px`,
};
}
return null;
}, [state.fixed, state.height, state.width]);

const stickyStyle = useMemo<CSSProperties | undefined>(() => {
if (!state.fixed) {
return null;
}

const style: CSSProperties = extend(getZIndexStyle(props.zIndex), {
width: `${state.width}px`,
height: `${state.height}px`,
[props.position]: `${offset}px`,
});

if (state.transform) {
style.transform = `translate3d(0, ${state.transform}px, 0)`;
}

return style;
}, [props.position, state.fixed, offset, state.width, state.height, state.transform]);

const emitScroll = (scrollTop: number, isFixed: boolean) => {
if (props.onScroll) {
props.onScroll({
scrollTop,
isFixed,
});
}
};

const onScroll = useCallback(() => {
if (!root.current || isHidden(root.current)) {
return;
}

const { container, position } = props;
const rootRect = getRect(root.current);
const scrollTop = getScrollTop(window);

const newState = {} as typeof state;
newState.width = rootRect.width;
newState.height = rootRect.height;

if (position === 'top') {
// The sticky component should be kept inside the container element
if (container) {
const containerRect = getRect(container.current);
const difference = containerRect.bottom - offset - newState.height;
newState.fixed = offset > rootRect.top && containerRect.bottom > 0;
newState.transform = difference < 0 ? difference : 0;
} else {
newState.fixed = offset > rootRect.top;
}
} else {
const { clientHeight } = document.documentElement;
if (container) {
const containerRect = getRect(container.current);
const difference = clientHeight - containerRect.top - offset - newState.height;
newState.fixed =
clientHeight - offset < rootRect.bottom && clientHeight > containerRect.top;
newState.transform = difference < 0 ? -difference : 0;
} else {
newState.fixed = clientHeight - offset < rootRect.bottom;
}
}
updateState(newState);
emitScroll(scrollTop, newState.fixed);
}, [offset]);

useEventListener('scroll', onScroll, {
target: scrollParent,
depends: [offset],
});
useVisibilityChange(root, onScroll);
useUpdateEffect(() => {
props.onChange?.(state.fixed);
}, [state.fixed]);

return (
<div ref={root} style={rootStyle}>
<div className="Sticky-fixed" style={stickyStyle}>
{props.children}
</div>
</div>
);
};

export default Sticky;
4 changes: 4 additions & 0 deletions packages/ims-view-pc/src/components/Sticky/demo/index.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.demo-sticky--wrapper {
overflow: scroll;
height: 1000px;
}
35 changes: 35 additions & 0 deletions packages/ims-view-pc/src/components/Sticky/demo/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Button } from 'antd';
import { Sticky } from 'ims-view-pc';
import React, { useRef } from 'react';
import './index.less';

export default () => {
const container = useRef<HTMLDivElement>(null);

return (
<div>
<div className="demo-sticky--wrapper">
<Sticky>
<Button type="primary" style={{ marginLeft: '15px' }}>
基础用法
</Button>
</Sticky>

<Sticky offsetTop={50}>
<Button style={{ marginLeft: '115px' }}>吸顶距离</Button>
</Sticky>

<div ref={container} style={{ height: '300px', backgroundColor: '#fff' }}>
<Sticky container={container}>
<Button style={{ marginLeft: '215px' }}>指定容器</Button>
</Sticky>
</div>

<div style={{ height: '70vh' }} />
<Sticky position="bottom" offsetBottom={50}>
<Button style={{ marginLeft: '15px' }}>吸底距离</Button>
</Sticky>
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/* eslint-disable react-hooks/rules-of-hooks */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useEffect } from 'react';
import { BasicTarget, getTargetElement, inBrowser, TargetElement } from './utils';

// https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener#使用_passive_改善的滚屏性能
export let supportsPassive = false;

if (inBrowser) {
try {
const opts = {};
Object.defineProperty(opts, 'passive', {
get() {
supportsPassive = true;
},
});
window.addEventListener('test-passive', null, opts);
// eslint-disable-next-line no-empty
} catch (e) {}
}

type Target = BasicTarget<TargetElement>;

export type UseEventListenerOptions = {
target?: Target;
capture?: boolean;
passive?: boolean;
depends?: Array<unknown>;
};

function useEventListener(
type: string,
listener: EventListener,
options: UseEventListenerOptions = {},
): void {
if (!inBrowser) {
return;
}
const { target = window, passive = false, capture = false, depends = [] } = options;
let attached: boolean;

const add = () => {
const element = getTargetElement(target);

if (element && !attached) {
element.addEventListener(type, listener, supportsPassive ? { capture, passive } : capture);
attached = true;
}
};

const remove = () => {
const element = getTargetElement(target);

if (element && attached) {
element.removeEventListener(type, listener, capture);
attached = false;
}
};

// https://stackoverflow.com/questions/55265255/react-usestate-hook-event-handler-using-initial-state
useEffect(() => {
add();
return () => remove();
}, [target, ...depends]);
}

export default useEventListener;
46 changes: 46 additions & 0 deletions packages/ims-view-pc/src/components/Sticky/hooks/useRect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
function isWindow(val: unknown): val is Window {
return val === window;
}
interface Rect {
top: number;
left: number;
right: number;
bottom: number;
width: number;
height: number;
}

const useRect = (elementRef: Element | Window): Rect => {
const element = elementRef;

if (isWindow(element)) {
const width = element.innerWidth;
const height = element.innerHeight;

return {
top: 0,
left: 0,
right: width,
bottom: height,
width,
height,
};
}

if (element && element.getBoundingClientRect) {
return element.getBoundingClientRect();
}

return {
top: 0,
left: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
};
};

export { useRect as getRect };

export default useRect;
22 changes: 22 additions & 0 deletions packages/ims-view-pc/src/components/Sticky/hooks/useRefState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { useCallback, useRef, useState } from 'react';
import { isFunction } from './utils';

type StateType<T> = T | (() => T);

export default function useRefState<T>(
initialState: StateType<T>,
): [T, Dispatch<SetStateAction<T>>, MutableRefObject<T>] {
const [state, setState] = useState<T>(initialState);
const ref = useRef(state);
const setRafState = useCallback(
(patch) => {
setState((prevState) => {
// eslint-disable-next-line no-return-assign
return (ref.current = isFunction(patch) ? patch(prevState) : patch);
});
},
[state],
);
return [state, setRafState, ref];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/* eslint-disable no-param-reassign */
import { MutableRefObject, useEffect, useState } from 'react';
import { BasicTarget, getTargetElement, inBrowser } from './utils';

type ScrollElement = Element | HTMLElement | Window;

const overflowScrollReg = /scroll|auto/i;
const defaultRoot = inBrowser ? window : undefined;

function isElement(node: Element) {
const ELEMENT_NODE_TYPE = 1;
return node.tagName !== 'HTML' && node.tagName !== 'BODY' && node.nodeType === ELEMENT_NODE_TYPE;
}

export function getScrollParent(el: Element, root: ScrollElement = defaultRoot): ScrollElement {
if (root === undefined) {
root = window;
}
let node = el;
while (node && node !== root && isElement(node)) {
const { overflowY } = window.getComputedStyle(node);
if (overflowScrollReg.test(overflowY)) {
if (node.tagName !== 'BODY') {
return node;
}

const htmlOverflowY = window.getComputedStyle(node.parentNode as Element).overflowY;
if (overflowScrollReg.test(htmlOverflowY)) {
return node;
}
}
node = node.parentNode as Element;
}
return root;
}

function useScrollParent(
el: BasicTarget<HTMLElement | Element | Window | Document>,
): Element | Window {
const [scrollParent, setScrollParent] = useState<Element | Window>();

useEffect(() => {
if (el) {
const element = getTargetElement(el) as Element;
setScrollParent(getScrollParent(element));
}
}, []);

return scrollParent;
}

export default useScrollParent;
Loading

0 comments on commit 893eb18

Please sign in to comment.