diff --git a/docs/config/sidebar.js b/docs/config/sidebar.js
index b1234fc..774857e 100644
--- a/docs/config/sidebar.js
+++ b/docs/config/sidebar.js
@@ -158,6 +158,10 @@ module.exports = [
{
title: 'ActionSheet 操作列表',
path: '/components/base/action-sheet'
+ },
+ {
+ title: 'FloatingPanel 悬浮面板',
+ path: '/components/base/floating-panel'
}
]
},
diff --git a/example/app.mpx b/example/app.mpx
index c1f9e3c..227b8db 100644
--- a/example/app.mpx
+++ b/example/app.mpx
@@ -50,6 +50,7 @@
"./pages/loading/index",
"./pages/input/index",
"./pages/action-sheet/index",
+ "./pages/floating-panel/index"
"./pages/tab-bar/index"
]
}
diff --git a/example/common/config.ts b/example/common/config.ts
index 8d82756..ff7c366 100644
--- a/example/common/config.ts
+++ b/example/common/config.ts
@@ -74,7 +74,8 @@ export default {
'dialog',
'modal',
'tip',
- 'action-sheet'
+ 'action-sheet',
+ 'floating-panel'
]
},
{
diff --git a/example/pages/floating-panel/README.md b/example/pages/floating-panel/README.md
new file mode 100644
index 0000000..a3d9070
--- /dev/null
+++ b/example/pages/floating-panel/README.md
@@ -0,0 +1,41 @@
+## Cube-FloatingPanel
+
+
+
+### 介绍
+
+浮层组件,浮动在页面底部的面板,可以上下拖动来浏览内容,常用于提供额外的功能或信息。支持使用`wx:model`对数据双向绑定。
+
+
+
+## 示例
+
+
+
+### 基础用法
+
+FloatingPanel 的默认高度为 `100px`,用户可以拖动来展开面板,使高度达到 `60%` 的屏幕高度。
+
+
+
+
+
+
+### 自定义锚点
+
+你可以通过 `anchors` 属性来设置 FloatingPanel 的锚点位置,并通过 `wx:model` 来控制当前面板的显示高度。
+
+比如,使面板的高度在 `100px`、`40%` 屏幕高度和 `70%` 屏幕高度三个位置停靠:
+
+
+
+
+
+
+### 仅头部拖拽
+
+默认情况下,FloatingPanel 的头部区域和内容区域都可以被拖拽,你可以通过 `contentDraggable` 属性来禁用内容区域的拖拽。
+
+
+
+
\ No newline at end of file
diff --git a/example/pages/floating-panel/floating-panel-anchors.mpx b/example/pages/floating-panel/floating-panel-anchors.mpx
new file mode 100644
index 0000000..063ef90
--- /dev/null
+++ b/example/pages/floating-panel/floating-panel-anchors.mpx
@@ -0,0 +1,34 @@
+
+
+
+ height: {{ height }} px
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/example/pages/floating-panel/floating-panel-contentDraggable.mpx b/example/pages/floating-panel/floating-panel-contentDraggable.mpx
new file mode 100644
index 0000000..6501376
--- /dev/null
+++ b/example/pages/floating-panel/floating-panel-contentDraggable.mpx
@@ -0,0 +1,37 @@
+
+
+
+
+ 内容不可拖拽
+
+
+
+
+
+
+
+
+
+
diff --git a/example/pages/floating-panel/floating-panel-default.mpx b/example/pages/floating-panel/floating-panel-default.mpx
new file mode 100644
index 0000000..52fa3e1
--- /dev/null
+++ b/example/pages/floating-panel/floating-panel-default.mpx
@@ -0,0 +1,31 @@
+
+
+
+ {{item}}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/example/pages/floating-panel/index.mpx b/example/pages/floating-panel/index.mpx
new file mode 100644
index 0000000..eace275
--- /dev/null
+++ b/example/pages/floating-panel/index.mpx
@@ -0,0 +1,47 @@
+
+
+ 我是背景
+
+
+ height: {{ height }} px
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/mpx-cube-ui/__tests__/components/floating-panel/__snapshots__/floating-panel.spec.js.snap b/packages/mpx-cube-ui/__tests__/components/floating-panel/__snapshots__/floating-panel.spec.js.snap
new file mode 100644
index 0000000..7b560be
--- /dev/null
+++ b/packages/mpx-cube-ui/__tests__/components/floating-panel/__snapshots__/floating-panel.spec.js.snap
@@ -0,0 +1,5 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`component floating-panel unit test props check default matchSnapshot 1`] = `""`;
+
+exports[`component floating-panel unit test slot render check matchSnapshot 1`] = `""`;
diff --git a/packages/mpx-cube-ui/__tests__/components/floating-panel/floating-panel.spec.js b/packages/mpx-cube-ui/__tests__/components/floating-panel/floating-panel.spec.js
new file mode 100644
index 0000000..76cafb8
--- /dev/null
+++ b/packages/mpx-cube-ui/__tests__/components/floating-panel/floating-panel.spec.js
@@ -0,0 +1,106 @@
+const simulate = require('@mpxjs/miniprogram-simulate')
+
+describe('component floating-panel unit test', () => {
+ const componentId = simulate.loadMpx('src/components/floating-panel/index.mpx')
+
+ function newComponent(props) {
+ const component = simulate.render(componentId, props)
+ component.attach(document.createElement('parent'))
+ return component
+ }
+
+ async function _snapTest(component) {
+ expect(component.dom.innerHTML).toMatchSnapshot()
+ }
+
+ const baseProps = {
+ height: 100,
+ anchors: [100, 500],
+ duration: 0.3
+ }
+
+ describe('props check default', () => {
+ const component = newComponent(baseProps)
+
+ it('matchSnapshot', () => {
+ _snapTest(component)
+ })
+
+ it('should render correct initial style', () => {
+ const root = component.querySelector('.cube-floating-panel')
+ // height: anchors[max] -> 500px
+ expect(root.dom.style.height).toBe('500px')
+ // transform: translateY(calc(100% + -100px)) because initial height is 100
+ expect(root.dom.style.transform).toBe('translateY(calc(100% + -100px))')
+ })
+ })
+
+ describe('slot render check', () => {
+ const component = simulate.render(simulate.load({
+ usingComponents: {
+ 'cube-floating-panel': componentId
+ },
+ template: `
+
+ test
+ `
+ }))
+ const parent = document.createElement('parent')
+ component.attach(parent)
+ it('matchSnapshot', () => {
+ _snapTest(component)
+ })
+
+ it('should render correct slot content', () => {
+ const content = component.querySelector('.slot-node').dom.innerHTML
+ expect(content).toBe('test')
+ })
+ })
+
+ describe('props check safeAreaInsetBottom', () => {
+ const props = Object.assign({}, baseProps, {
+ safeAreaInsetBottom: false
+ })
+ const component = newComponent(props)
+ it('should not have safe-area class', () => {
+ const root = component.querySelector('.cube-floating-panel')
+ expect(root.dom.classList.contains('cube-floating-panel-safe-area-bottom')).toBe(false)
+ })
+ })
+
+ describe('props check contentDraggable', () => {
+ it('should drag when contentDraggable is true (default)', async () => {
+ // This is covered by default props, but we can test on content element
+ const component = newComponent(baseProps)
+ const content = component.querySelector('.cube-floating-panel-content')
+ const start = { touches: [{ pageY: 500 }] }
+ const move = { touches: [{ pageY: 450 }] }
+ const inputHandler = jest.fn()
+ component.addEventListener('input', inputHandler)
+
+ content.dispatchEvent('touchstart', start)
+ content.dispatchEvent('touchmove', move)
+ await simulate.sleep(10)
+
+ expect(inputHandler).toHaveBeenCalled()
+ })
+
+ it('should not drag when contentDraggable is false', async () => {
+ const props = Object.assign({}, baseProps, {
+ contentDraggable: false
+ })
+ const component = newComponent(props)
+ const content = component.querySelector('.cube-floating-panel-content')
+ const start = { touches: [{ pageY: 500 }] }
+ const move = { touches: [{ pageY: 450 }] }
+ const inputHandler = jest.fn()
+ component.addEventListener('input', inputHandler)
+
+ content.dispatchEvent('touchstart', start)
+ content.dispatchEvent('touchmove', move)
+ await simulate.sleep(10)
+
+ expect(inputHandler).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/packages/mpx-cube-ui/src/common/stylus/theme/components/floating-panel.styl b/packages/mpx-cube-ui/src/common/stylus/theme/components/floating-panel.styl
new file mode 100644
index 0000000..2b6302f
--- /dev/null
+++ b/packages/mpx-cube-ui/src/common/stylus/theme/components/floating-panel.styl
@@ -0,0 +1,21 @@
+// @type floating-panel
+$floating-panel-z-index := 999 // 浮层z-index
+$floating-panel-top-left-radius := 16px // 浮层左上角圆角
+$floating-panel-top-right-radius := 16px // 浮层右上角圆角
+$floating-panel-bottom-right-radius := 0 // 浮层右下角圆角
+$floating-panel-bottom-left-radius := 0 // 浮层左下角圆角
+$floating-panel-radius := $floating-panel-top-left-radius $floating-panel-top-right-radius $floating-panel-bottom-right-radius $floating-panel-bottom-left-radius // 边框圆角
+$floating-panel-bgc := $var(color-white) // 浮层背景颜色
+$floating-panel-header-height := 30px // 浮层标题高度
+$floating-panel-bar-width := 20px // 浮层拖动条宽度
+$floating-panel-bar-height := 3px // 浮层拖动条高度
+$floating-panel-bar-color := #c8c9cc // 浮层拖动条颜色
+$floating-panel-border-color := #c8c9cc // 浮层边框颜色
+
+
+// @type floating-panel-bottom
+$floating-panel-padding-top := 0 // 浮层区域顶部内边距
+$floating-panel-padding-right := 0 // 浮层内容区域右侧内边距
+$floating-panel-padding-bottom := 20px // 浮层内容区域底部内边距
+$floating-panel-padding-left := 0 // 浮层内容区域左侧内边距
+$floating-panel-padding := $floating-panel-padding-top $floating-panel-padding-right $floating-panel-padding-bottom $floating-panel-padding-left // 浮层内容区域内边距
diff --git a/packages/mpx-cube-ui/src/components/floating-panel/css.rn.styl b/packages/mpx-cube-ui/src/components/floating-panel/css.rn.styl
new file mode 100644
index 0000000..983d66a
--- /dev/null
+++ b/packages/mpx-cube-ui/src/components/floating-panel/css.rn.styl
@@ -0,0 +1,2 @@
+@require "../../common/stylus/variable.styl"
+@require "../../common/stylus/mixin.styl"
\ No newline at end of file
diff --git a/packages/mpx-cube-ui/src/components/floating-panel/css.styl b/packages/mpx-cube-ui/src/components/floating-panel/css.styl
new file mode 100644
index 0000000..398804c
--- /dev/null
+++ b/packages/mpx-cube-ui/src/components/floating-panel/css.styl
@@ -0,0 +1,37 @@
+@require "../../common/stylus/variable.styl"
+@require "../../common/stylus/mixin.styl"
+@require "../../common/stylus/theme/components/floating-panel.styl"
+
+.cube-floating-panel
+ position fixed
+ z-index $var(floating-panel-z-index)
+ left 0
+ bottom 0
+ width 100vw
+ display flex
+ flex-direction column
+ background-color $var(floating-panel-bgc)
+ border-radius $var(floating-panel-radius)
+.cube-floating-panel-extender
+ position absolute
+ bottom -100vh
+ height 100vh
+ width 100vw
+ background-color inherit
+ pointer-events none
+.cube-floating-panel-header
+ display flex
+ align-items center
+ justify-content center
+ height $var(floating-panel-header-height)
+.cube-floating-panel-header-bar
+ width $var(floating-panel-bar-width)
+ height $var(floating-panel-bar-height)
+ border-radius $var(floating-panel-bar-radius)
+ background-color $var(floating-panel-bar-color)
+.cube-floating-panel-content
+ flex 1
+ overflow-y auto
+.cube-floating-panel-safe-area-bottom
+ padding-all($var(floating-panel-padding-top), $var(floating-panel-padding-right), $var(floating-panel-padding-bottom), $var(floating-panel-padding-left))
+ safe-area-mixin-extra(padding-bottom, bottom, $var(floating-panel-safe-padding), true)
diff --git a/packages/mpx-cube-ui/src/components/floating-panel/floating-panel.ts b/packages/mpx-cube-ui/src/components/floating-panel/floating-panel.ts
new file mode 100644
index 0000000..efecba4
--- /dev/null
+++ b/packages/mpx-cube-ui/src/components/floating-panel/floating-panel.ts
@@ -0,0 +1,169 @@
+import mpx from '@mpxjs/core'
+import { createComponent } from '../../common/helper/create-component'
+
+const EVENT_INPUT = 'input'
+const WINDOW_HEIGHT = mpx.getSystemInfoSync().windowHeight
+const DEFAULT_HEIGHT = 100
+const DEFAULT_EXPAND_HEIGHT = Math.round(WINDOW_HEIGHT * 0.6)
+const DAMP = 0.2
+
+const regExp = /^-?\d+(\.\d+)?$/
+const addUnit = (value: number | string) => (regExp.test('' + value) ? value + 'px' : value)
+export function closest(arr: number[], target: number) {
+ return arr.reduce((pre, cur) =>
+ Math.abs(pre - target) < Math.abs(cur - target) ? pre : cur
+ )
+}
+
+createComponent({
+ options: {
+ multipleSlots: true
+ },
+ properties: {
+ // 当前面板的显示高度
+ height: {
+ type: Number,
+ optionalTypes: [String],
+ value: 0
+ },
+ // 设置自定义锚点, 单位 px
+ anchors: {
+ type: Array,
+ value: [DEFAULT_HEIGHT, DEFAULT_EXPAND_HEIGHT]
+ },
+ // 拖拽结束时是否吸附到预设锚点
+ magnetic: {
+ type: Boolean,
+ value: true
+ },
+ // 动画时长,单位秒,设置为 0 可以禁用动画
+ duration: {
+ type: Number,
+ optionalTypes: [String],
+ value: 0.3
+ },
+ // 允许拖拽内容容器
+ contentDraggable: {
+ type: Boolean,
+ value: true
+ },
+ // 是否开启底部安全区适配
+ safeAreaInsetBottom: {
+ type: Boolean,
+ value: true
+ },
+ // 不展示默认拖动条
+ hideHeaderBar: {
+ type: Boolean,
+ value: false
+ }
+ },
+ data: {
+ heightVal: 0,
+ dragging: false,
+ startY: 0,
+ startPageY: 0,
+ deltaY: 0,
+ maxScroll: -1,
+ contentScrollTop: 0
+ },
+ computed: {
+ _anchors() {
+ const list: number[] = Array.isArray(this.anchors) ? this.anchors : []
+ const min = list[0] ?? DEFAULT_HEIGHT
+ const max = list.length ? list[list.length - 1] : DEFAULT_EXPAND_HEIGHT
+ return list.length >= 2 ? list : [min, max]
+ },
+ boundary() {
+ const list: number[] = this._anchors
+ return {
+ min: list[0] ?? DEFAULT_HEIGHT,
+ max: list[list.length - 1] ?? DEFAULT_EXPAND_HEIGHT
+ }
+ },
+ rootStyle() {
+ const h = this.heightVal
+ const { max } = this.boundary
+ return {
+ height: addUnit(max),
+ transform: `translateY(calc(100% + ${addUnit(-h)}))`,
+ transition: this.dragging
+ ? 'none'
+ : `transform ${this.duration}s cubic-bezier(0.18, 0.89, 0.32, 1.28)`
+ }
+ }
+ },
+ watch: {
+ height: {
+ handler(newVal: number | string) {
+ const val = +newVal || 0
+ const { min, max } = this.boundary
+ this.heightVal = this.dragging ? val : Math.round(Math.max(min, Math.min(max, val)))
+ },
+ immediate: true
+ },
+ anchors: {
+ handler() {
+ this.heightVal = Math.round(closest(this._anchors, this.heightVal))
+ }
+ }
+ },
+ methods: {
+ ease(moveY: number) {
+ const absDistance = Math.abs(moveY)
+ const { min, max } = this.boundary
+ if (absDistance > max) {
+ return -(max + (absDistance - max) * DAMP)
+ }
+ if (absDistance < min) {
+ return -(min - (min - absDistance) * DAMP)
+ }
+ return moveY
+ },
+ onTouchStart(e: TouchEvent) {
+ const touch = e.touches && e.touches[0]
+ this.dragging = true
+ this.startPageY = touch ? touch.pageY : 0
+ this.startY = -this.heightVal
+ this.maxScroll = -1
+ },
+ onTouchMove(e: TouchEvent) {
+ const touch = e.touches && e.touches[0]
+ if (!touch) return
+ this.deltaY = touch.pageY - this.startPageY
+ const moveY = this.deltaY + this.startY
+ const val = Math.round(-this.ease(moveY))
+ this.heightVal = val
+ this.triggerEvent(EVENT_INPUT, { value: val })
+ },
+ onTouchEnd() {
+ this.maxScroll = -1
+ this.dragging = false
+ const { min, max } = this.boundary
+ if (this.magnetic) {
+ this.heightVal = this._anchors && this._anchors.length
+ ? closest(this._anchors, this.heightVal)
+ : Math.max(min, Math.min(max, this.heightVal))
+ } else {
+ this.heightVal = Math.max(min, Math.min(max, this.heightVal))
+ }
+ this.heightVal = Math.round(this.heightVal)
+ this.triggerEvent(EVENT_INPUT, { value: this.heightVal })
+ },
+ onContentScroll(e: any) {
+ const detail = e && e.detail
+ const fromDetail = typeof detail?.scrollTop === 'number' ? detail.scrollTop : undefined
+ const fromTarget = e ? (e.target?.scrollTop ?? e.currentTarget?.scrollTop) : undefined
+ const st = typeof fromDetail === 'number' ? fromDetail : fromTarget
+ this.contentScrollTop = typeof st === 'number' ? st : 0
+ },
+ onContentTouchStart(e: TouchEvent) {
+ if (!this.contentDraggable) return
+ this.onTouchStart(e)
+ },
+ onContentTouchMove(e: TouchEvent) {
+ if (!this.contentDraggable) return
+ this.onTouchMove(e)
+ }
+ }
+})
diff --git a/packages/mpx-cube-ui/src/components/floating-panel/index.mpx b/packages/mpx-cube-ui/src/components/floating-panel/index.mpx
new file mode 100644
index 0000000..7d27239
--- /dev/null
+++ b/packages/mpx-cube-ui/src/components/floating-panel/index.mpx
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+