diff --git a/components/_style.ts b/components/_style.ts index 51612f96..6af5d045 100644 --- a/components/_style.ts +++ b/components/_style.ts @@ -61,3 +61,4 @@ import './input-file/style'; import './breadcrumb/style'; import './text-highlight/style'; import './link/style'; +import './floating-pane/style'; diff --git a/components/_util/getPrefixStorage.ts b/components/_util/getPrefixStorage.ts new file mode 100644 index 00000000..94cbc25b --- /dev/null +++ b/components/_util/getPrefixStorage.ts @@ -0,0 +1,5 @@ +const prefixStorage = 'fes-storage'; + +export default function getPrefixStorage(suffix: string) { + return suffix ? `${prefixStorage}-${suffix}` : prefixStorage; +} diff --git a/components/components.ts b/components/components.ts index f3d655c1..b6cf5f19 100644 --- a/components/components.ts +++ b/components/components.ts @@ -59,3 +59,4 @@ export * from './input-file'; export * from './breadcrumb'; export * from './text-highlight'; export * from './link'; +export * from './floating-pane'; diff --git a/components/floating-pane/floating-pane.tsx b/components/floating-pane/floating-pane.tsx new file mode 100644 index 00000000..cb56400f --- /dev/null +++ b/components/floating-pane/floating-pane.tsx @@ -0,0 +1,153 @@ +import { + Teleport, + Transition, + computed, + defineComponent, + nextTick, + ref, + watch, +} from 'vue'; +import { isNumber } from 'lodash-es'; +import { useStorage } from '@vueuse/core'; +import getPrefixCls from '../_util/getPrefixCls'; +import { CloseOutlined } from '../icon'; +import { useTheme } from '../_theme/useTheme'; +import { useConfig } from '../config-provider'; +import getPrefixStorage from '../_util/getPrefixStorage'; +import { floatingPaneProps } from './props'; +import { useDrag } from './useDrag'; + +const prefixCls = getPrefixCls('floating-pane'); +const UPDATE_VISIBLE_EVENT = 'update:visible'; +const AFTER_ENTER_EVENT = 'after-enter'; +const AFTER_LEAVE_EVENT = 'after-leave'; + +const FloatingPane = defineComponent({ + name: 'FFloatingPane', + props: floatingPaneProps, + emits: [ + UPDATE_VISIBLE_EVENT, + AFTER_ENTER_EVENT, + AFTER_LEAVE_EVENT, + ], + setup(props, ctx) { + useTheme(); + const innerVisible = ref(false); + watch( + () => props.visible, + () => { + nextTick(() => { + innerVisible.value = props.visible; + }); + }, + { immediate: true }, + ); + const config = useConfig(); + const getContainer = computed( + () => props.getContainer || config.getContainer?.value, + ); + + function handleCancel() { + ctx.emit(UPDATE_VISIBLE_EVENT, false); + } + + function handleTransitionAfterEnter(el: Element) { + ctx.emit(AFTER_ENTER_EVENT, el); + } + function handleTransitionAfterLeave(el: Element) { + ctx.emit(AFTER_LEAVE_EVENT, el); + } + + const hasHeader = computed(() => ctx.slots.title || props.title); + + function getHeader() { + const closeJsx = ( +
+ +
+ ); + if (!hasHeader.value) { + return closeJsx; + } + const header = ctx.slots.title?.() || props.title; + return ( +
+
{header}
+ {closeJsx} +
+ ); + } + + const transform = props.cachePrePosition + ? useStorage<{ + offsetX: number; + offsetY: number; + }>(getPrefixStorage('floating-pane'), { + offsetX: 0, + offsetY: 0, + }, window[props.cachePrePosition]) + : ref({ + offsetX: 0, + offsetY: 0, + }); + const styles = computed(() => { + const { offsetX, offsetY } + = transform.value; + return { + zIndex: props.zIndex, + width: isNumber(props.width) ? `${props.width}px` : props.width, + ...props.defaultPosition, + transform: `translate(${offsetX}px, ${offsetY}px)`, + }; + }); + + const { handleMouseDown } = useDrag(transform); + + const getBody = () => { + return ( +
+ {ctx.slots.default?.()} +
+ ); + }; + + const showDom = computed( + () => + (props.displayDirective === 'if' && innerVisible.value) + || props.displayDirective === 'show', + ); + + const wrapperClass = computed(() => { + return [`${prefixCls}-container`, props.contentClass].filter(Boolean); + }); + + return () => ( + +
+ + {showDom.value && ( +
+ {getHeader()} + {getBody()} +
+ )} +
+
+
+ ); + }, +}); + +export default FloatingPane; diff --git a/components/floating-pane/index.ts b/components/floating-pane/index.ts new file mode 100644 index 00000000..fa81a94e --- /dev/null +++ b/components/floating-pane/index.ts @@ -0,0 +1,11 @@ +import { withInstall } from '../_util/withInstall'; +import type { SFCWithInstall } from '../_util/interface'; +import FloatingPane from './floating-pane'; + +type ModalType = SFCWithInstall; + +export { floatingPaneProps } from './props'; +export type { FloatingPaneProps } from './props'; +export const FFloatingPane = withInstall(FloatingPane as ModalType); + +export default FFloatingPane; diff --git a/components/floating-pane/props.ts b/components/floating-pane/props.ts new file mode 100644 index 00000000..d5ecc0c3 --- /dev/null +++ b/components/floating-pane/props.ts @@ -0,0 +1,47 @@ +import type { ComponentObjectPropsOptions, PropType, VNode, VNodeChild } from 'vue'; +import type { ExtractPublicPropTypes } from '../_util/interface'; + +export interface PanePosition { + top?: string; + right?: string; + bottom?: string; + left?: string; +} + +// 通用的属性 +export const floatingPaneProps = { + visible: Boolean, + displayDirective: { + type: String as PropType<'show' | 'if'>, + default: 'show', + }, + title: String as PropType VNodeChild)>, + width: { + type: [String, Number] as PropType, + default: 520, + }, + zIndex: { + type: Number, + default: 3000, + }, + defaultPosition: { + type: Object as PropType, + default(): PanePosition { + return { + top: '50px', + right: '50px', + }; + }, + }, + cachePrePosition: { + type: String as PropType<'sessionStorage' | 'localStorage'>, + default: 'localStorage', + }, + getContainer: { + type: Function as PropType<() => HTMLElement>, + }, + // 内容外层类名 + contentClass: String, +} as const satisfies ComponentObjectPropsOptions; + +export type FloatingPaneProps = ExtractPublicPropTypes; diff --git a/components/floating-pane/style/index.less b/components/floating-pane/style/index.less new file mode 100644 index 00000000..0b12068b --- /dev/null +++ b/components/floating-pane/style/index.less @@ -0,0 +1,83 @@ +@import '../../style/themes/index'; +@import '../../style/mixins/index'; + +@modal-prefix-cls: ~'@{cls-prefix}-floating-pane'; + +.@{modal-prefix-cls} { + .default(); + .text(); + + &-container { + position: fixed; + box-sizing: border-box; + background: var(--f-body-bg-color); + border-radius: var(--f-border-radius-base); + box-shadow: @shadow-down; + } + + &-header { + position: relative; + + --f-modal-header-icon-color: inherit; + display: flex; + align-items: center; + padding: @padding-md @padding-sm; + color: var(--f-head-color); + font-weight: @font-weight-medium; + font-size: @font-size-head; + border-bottom: var(--f-border-width-base) var(--f-border-style-base) var(--f-border-color-base); + + .@{modal-prefix-cls}-icon { + display: flex; + align-items: center; + padding-right: @padding-sm; + color: var(--f-modal-header-icon-color); + font-size: @font-size-title; + } + + .@{modal-prefix-cls}-status-info { + --f-modal-header-icon-color: var(--f-primary-color); + } + + .@{modal-prefix-cls}-status-success { + --f-modal-header-icon-color: var(--f-success-color); + } + + .@{modal-prefix-cls}-status-error { + --f-modal-header-icon-color: var(--f-danger-color); + } + + .@{modal-prefix-cls}-status-confirm, + .@{modal-prefix-cls}-status-warning { + --f-modal-header-icon-color: var(--f-warning-color); + } + } + + &-body { + padding: @padding-xs 0; + color: var(--f-sub-head-color); + font-size: var(--f-font-size-base); + } + + &-close { + position: absolute; + top: auto; + right: 0; + padding: 0 @padding-sm; + color: var(--f-sub-head-color); + font-size: @font-size-head; + line-height: 0; + cursor: pointer; + } + + &-fade-leave-active, + &-fade-enter-active { + transition: all @animation-duration-slow @ease-base-out; + } + + &-fade-leave-to, + &-fade-enter-from { + transform: scale(0); + opacity: 0; + } +} diff --git a/components/floating-pane/style/index.ts b/components/floating-pane/style/index.ts new file mode 100644 index 00000000..ed51d175 --- /dev/null +++ b/components/floating-pane/style/index.ts @@ -0,0 +1,2 @@ +import '../../style'; +import './index.less'; diff --git a/components/floating-pane/useDrag.ts b/components/floating-pane/useDrag.ts new file mode 100644 index 00000000..1e67cc1c --- /dev/null +++ b/components/floating-pane/useDrag.ts @@ -0,0 +1,56 @@ +import type { Ref } from 'vue'; +import { throttle } from 'lodash-es'; +import { useEventListener } from '@vueuse/core'; + +export const useDrag = ( + transform: Ref<{ + offsetX: number; + offsetY: number; + }>, +) => { + let isMouseDown = false; + + let startX: number; + let startY: number; + let imgOffsetX: number; + let imgOffsetY: number; + + const handleMouseDown = (event: MouseEvent) => { + // 取消默认图片拖拽的行为 + event.preventDefault(); + isMouseDown = true; + // 存储鼠标按下的偏移量和事件发生坐标 + const { offsetX, offsetY } = transform.value; + startX = event.pageX; + startY = event.pageY; + imgOffsetX = offsetX; + imgOffsetY = offsetY; + }; + + const handleDrag = throttle((event: MouseEvent) => { + transform.value = { + ...transform.value, + offsetX: imgOffsetX + event.pageX - startX, + offsetY: imgOffsetY + event.pageY - startY, + }; + }); + + // mousemove 事件监听 document 拖拽效果更流畅 + useEventListener(document, 'mousemove', (event) => { + if (!isMouseDown) { + return; + } + handleDrag(event); + }); + + useEventListener(document, 'mouseup', () => { + if (!isMouseDown) { + return; + } + isMouseDown = false; + }); + + return { + handleMouseDown, + }; +}; diff --git a/docs/.vitepress/components/floating-pane/common.vue b/docs/.vitepress/components/floating-pane/common.vue new file mode 100644 index 00000000..e931a70f --- /dev/null +++ b/docs/.vitepress/components/floating-pane/common.vue @@ -0,0 +1,35 @@ + + + diff --git a/docs/.vitepress/components/floating-pane/index.md b/docs/.vitepress/components/floating-pane/index.md new file mode 100644 index 00000000..9f16a314 --- /dev/null +++ b/docs/.vitepress/components/floating-pane/index.md @@ -0,0 +1,58 @@ +# 浮动弹 + +浮动弹窗 + +## 组件注册 + +```js +import { FFloatingPane } from '@fesjs/fes-design'; + +app.use(FFloatingPane); +``` + +## 代码演示 + +### 基础用法 + +:::demo +common.vue +::: + +## Props + +| 属性 | 说明 | 类型 | 默认值 | +| ---------------- | ------------------------------------------------------------------------------ | ----------------- | ------------------------------ | +| visible | v-model:visible,是否显示模态框 | Boolean | `false` | +| displayDirective | 选择渲染使用的指令,if 对应 v-if,show 对应 v-show,使用 show 的时候不会被重置 | string | `show` | +| title | 标题 | String | `-` | +| width | 宽度 | String/Number | 520 | +| zIndex | 浮层优先级 | Number | 3000 | +| defaultPosition | 默认弹窗位置 | PanePosition | `{top: '50px', right: '50px'}` | +| cachePrePosition | 是否缓存上次拖动位置 | `session` `local` | `local` | +| contentClass | 可用于设置内容的类名 | String | `-` | +| getContainer | 指定 `Modal` 挂载的 HTML 节点 | () => HTMLElement | `() => document.body` | + +### PanePosition + +```ts +interface PanePosition { + top?: string; + right?: string; + bottom?: string; + left?: string; +} +``` + +## Event + +| 事件名称 | 说明 | 回调参数 | +| ---------- | ------------------ | -------- | +| afterEnter | Modal 出现后的回调 | event | +| afterLeave | Modal 关闭后的回调 | event | + +## Slots + +| 名称 | 说明 | +| ------- | ------------ | +| default | 模态框的内容 | +| title | 模态框的标题 | diff --git a/docs/.vitepress/configs/sidebar/index.ts b/docs/.vitepress/configs/sidebar/index.ts index 78ad5230..3c3e0a1d 100644 --- a/docs/.vitepress/configs/sidebar/index.ts +++ b/docs/.vitepress/configs/sidebar/index.ts @@ -259,6 +259,10 @@ const sidebarConfig: Record = { text: 'Popper 弹出信息', link: '/zh/components/popper', }, + { + text: 'FloatingPane 可移动弹窗', + link: '/zh/components/floating-pane', + }, ], }, { @@ -271,9 +275,7 @@ const sidebarConfig: Record = { ], }, ].map((sidebarItem) => { - sidebarItem.items.sort(({ text: text1 }, { text: text2 }) => - text1 < text2 ? -1 : 1, - ); + sidebarItem.items.sort(({ text: text1 }, { text: text2 }) => (text1 < text2 ? -1 : 1)); return sidebarItem; }), '/zh/guide/': [