Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
155 changes: 155 additions & 0 deletions packages/rc-ui-lib/src/floating-bubble/FloatingBubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import classnames from 'classnames';
import { FloatingBubbleProps } from './PropsType';
import ConfigProviderContext from '../config-provider/ConfigProviderContext';
import { renderToContainer } from '../utils/dom/renderToContainer';
import { Icon } from '../icon';
import getRect from '../hooks/get-rect';
import useWindowSize from '../hooks/use-window-size';
import { addUnit, pick } from '../utils';
import { useTouch } from '../hooks/use-touch';
import useEventListener from '../hooks/use-event-listener';
import { closest } from '../utils/closest';
import { useSetState } from '../hooks';

const FloatingBubble: React.FC<FloatingBubbleProps> = (props) => {
const { prefixCls, createNamespace } = useContext(ConfigProviderContext);
const [bem] = createNamespace('floating-bubble', prefixCls);
const { offset, axis, gap, magnetic, icon, onClick, teleport, children } = props;
const windowSize = useWindowSize();
const touch = useTouch();
const rootRef = useRef<HTMLDivElement>();
const prevX = useRef(0);
const prevY = useRef(0);
const [state, setState] = useSetState({
x: 0,
y: 0,
width: 0,
height: 0,
});

const [dragging, setDragging] = useState(false);
const [show, setShow] = useState(false);
const [initialized, setInitialized] = useState(false);
const boundary = useMemo(() => {
return {
top: gap,
right: windowSize.width - state.width - gap,
bottom: windowSize.height - state.height - gap,
left: gap,
};
}, [windowSize, gap, state]);

const rootStyle = useMemo(() => {
const style: React.CSSProperties = {};
const x = addUnit(state.x);
const y = addUnit(state.y);
style.transform = `translate3d(${x}, ${y}, 0)`;
if (dragging || !initialized) {
style.transition = 'none';
}
return style;
}, [state, dragging, initialized]);
const updateState = () => {
const { width, height } = getRect(rootRef.current);
setState({
x: offset.x > 1 ? offset.x : windowSize.width - width - gap,
y: offset.y > 1 ? offset.y : windowSize.height - height - gap,
width,
height,
});
};
const onTouchStart = (event) => {
touch.start(event);
setDragging(true);
prevX.current = state.x;
prevY.current = state.y;
};
const onTouchMove = (event) => {
event.preventDefault();
touch.move(event);
if (axis === 'lock') return;
if (!touch.isTap.current) {
if (axis === 'x' || axis === 'xy') {
if (touch.deltaX.current === 0) return;
let nextX = prevX.current + touch.deltaX.current;
if (nextX < boundary.left) nextX = boundary.left;
if (nextX > boundary.right) nextX = boundary.right;
setState({ x: nextX });
}
if (axis === 'y' || axis === 'xy') {
let nextY = prevY.current + touch.deltaY.current;
if (nextY < boundary.top) nextY = boundary.top;
if (nextY > boundary.bottom) nextY = boundary.bottom;
setState({ y: nextY });
}
}
};
useEventListener('touchmove', onTouchMove, {
target: rootRef.current,
});
const onTouchEnd = () => {
setDragging(false);
setTimeout(() => {
if (magnetic === 'x') {
const nextX = closest([boundary.left, boundary.right], state.x);
setState({ x: nextX });
}
if (magnetic === 'y') {
const nextY = closest([boundary.top, boundary.bottom], state.y);
setState({ y: nextY });
}
if (!touch.isTap.current) {
const updateOffset = pick(state, ['x', 'y']);
if (prevX.current !== updateOffset.x || prevY.current !== updateOffset.y) {
props?.onOffsetChange?.(updateOffset);
}
}
}, 0);
};
useEffect(() => {
setShow(true);
setTimeout(() => {
updateState();
setInitialized(true);
}, 0);
return () => {
if (teleport) setShow(false);
};
}, []);

const renderContent = () => {
return (
show && (
<div
className={classnames(bem())}
ref={rootRef}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
onClick={(e) => {
if (touch.isTap.current) {
onClick(e);
}
}}
style={rootStyle}
>
{children || <Icon name={icon} className={classnames(bem('icon'))} />}
</div>
)
);
};
return teleport ? renderToContainer(teleport, renderContent()) : renderContent();
};

FloatingBubble.defaultProps = {
gap: 24,
axis: 'y',
offset: {
x: -1,
y: -1,
},
teleport: () => document.body,
};

export default FloatingBubble;
48 changes: 48 additions & 0 deletions packages/rc-ui-lib/src/floating-bubble/PropsType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ReactNode } from 'react';
import { BaseTypeProps, TeleportType } from '../utils';

export type FloatingBubbleThemeVars = {
floatingBubbleSize?: string;
floatingBubbleInitialGap?: string;
floatingBubbleIconSize?: string;
floatingBubbleBackground?: string;
floatingBubbleColor?: string;
floatingBubbleZIndex?: number | string;
};

export type FloatingBubbleAxis = 'x' | 'y' | 'xy' | 'lock';

export type FloatingBubbleMagnetic = 'x' | 'y';

export type FloatingBubbleOffset = {
x: number;
y: number;
};

export type FloatingBubbleBoundary = {
top: number;
right: number;
bottom: number;
left: number;
};

export interface FloatingBubbleProps extends BaseTypeProps {
/** 气泡初始位置 */
offset?: FloatingBubbleOffset;
/** 拖拽的方向 */
axis?: FloatingBubbleAxis;
/** 自动磁吸的方向 */
magnetic?: FloatingBubbleMagnetic;
/** 气泡图标名称或图片链接 */
icon?: string;
/** 气泡与窗口的最小间距 */
gap?: number;
/** 指定挂载的节点 */
teleport?: TeleportType;
/** 点击组件时触发 */
onClick?: (event: React.MouseEvent) => void;
/** 由用户拖拽结束导致位置改变后触发 */
onOffsetChange?: (offset: FloatingBubbleOffset) => void;
/** 子元素 */
children?: ReactNode;
}
86 changes: 86 additions & 0 deletions packages/rc-ui-lib/src/floating-bubble/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# FloatingBubble 浮动气泡

### 介绍

悬浮在页面边缘的可点击气泡。请升级 `rc-ui-lib` 到 >= 2.1.0 版本来使用该组件。

### 引入

```js
import { FloatingBubble } from 'rc-ui-lib';
```

## 代码演示

### 基础用法

浮动气泡默认展示在右下角,并允许在 y 轴方向上下拖拽,你可以通过 `icon` 属性设置气泡的图标。

```jsx
const onClick = () => {
Toast('点击气泡');
};
<FloatingBubble icon="chat" onClick={onClick} />;
```

### 自由拖拽和磁吸

允许 x 和 y 轴方向拖拽,吸附到 x 轴方向最近一边。

```jsx
const onOffsetChange = (offset) => {
Toast(`x: ${offset.x.toFixed(0)}, y: ${offset.y.toFixed(0)}`);
};
<FloatingBubble axis="xy" icon="chat" magnetic="x" onOffsetChange={onOffsetChange} />;
```

## API

### Props

| 参数 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
| offset | 气泡初始位置 | _OffsetType_ | `默认右下角坐标` |
| axis | 拖拽的方向,`xy` 代表自由拖拽,`lock` 代表禁止拖拽 | _'x' \| 'y' \| 'xy' \| 'lock'_ | `y` |
| magnetic | 自动磁吸的方向 | _'x' \| 'y'_ | - |
| icon | 气泡图标名称或图片链接,等同于 Icon 组件的 name 属性 | _string_ | - |
| gap | 气泡与窗口的最小间距,单位为 `px` | _number_ | `24` |
| teleport | 指定挂载的节点 | _HTMLElment () => HTMLElement_ | `body` |

### Events

| 事件名 | 说明 | 回调参数 |
| -------------- | ---------------------------- | ------------------------ |
| onClick | 点击组件时触发 | _MouseEvent_ |
| onOffsetChange | 由用户拖拽结束位置改变后触发 | _{x: string, y: string}_ |

### 类型定义

组件导出以下类型定义:

```ts
export type {
FloatingBubbleProps,
FloatingBubbleThemeVars,
FloatingBubbleAxis,
FloatingBubbleMagnetic,
FloatingBubbleOffset,
FloatingBubbleBoundary,
} from 'rc-ui-lib';
```

## 主题定制

### 样式变量

组件提供了下列 CSS 变量,可用于自定义样式,使用方法请参考 [ConfigProvider 组件](#/zh-CN/config-provider)。

| 名称 | 默认值 | 描述 |
| ---------------------------------- | ------------------------------------ | ---- |
| --rc-floating-bubble-size | _48px_ | - |
| --rc-floating-bubble-initial-gap | _24px_ | - |
| --rc-floating-bubble-icon-size | _28px_ | - |
| --rc-floating-bubble-background | _var(--rc-primary-color)_ | - |
| --rc-floating-bubble-color | _var(--rc-primary-color)_ | - |
| --rc-floating-bubble-z-index | _999_ | - |
| --rc-floating-bubble-border-radius | _--rc-floating-bubble-border-radius_ | - |
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`FloatingBubble should render correctly when icon set 1`] = `<div />`;

exports[`FloatingBubble should teleport body when default 1`] = `<div />`;
Loading