From e08487ca8197288da1a25f55a1a11d6d7961e8e7 Mon Sep 17 00:00:00 2001 From: LuPing Zou <“zouluping@inspur.com”> Date: Wed, 22 May 2024 20:05:27 +0800 Subject: [PATCH] feature: add mobile component fm-list --- .../mobile-ui-vue/components/hook/index.ts | 1 + .../hook/use-resize-observer/index.ts | 85 ++++++++ .../hook/use-resize-observer/utils.ts | 37 ++++ packages/mobile-ui-vue/components/index.scss | 1 + packages/mobile-ui-vue/components/index.ts | 3 + .../mobile-ui-vue/components/list/index.ts | 9 + .../components/list/src/composition/types.ts | 27 +++ .../use-scroll-reach-edge-check.ts | 86 ++++++++ .../components/list/src/list.component.tsx | 192 ++++++++++++++++++ .../components/list/src/list.props.ts | 62 ++++++ .../components/list/src/list.scss | 26 +++ packages/mobile-ui-vue/demos/list/base.vue | 31 +++ packages/mobile-ui-vue/demos/list/error.vue | 43 ++++ packages/mobile-ui-vue/demos/list/index.vue | 87 ++++++++ packages/mobile-ui-vue/demos/list/manual.vue | 45 ++++ packages/mobile-ui-vue/src/menu-data.ts | 11 + 16 files changed, 746 insertions(+) create mode 100644 packages/mobile-ui-vue/components/hook/use-resize-observer/index.ts create mode 100644 packages/mobile-ui-vue/components/hook/use-resize-observer/utils.ts create mode 100644 packages/mobile-ui-vue/components/list/index.ts create mode 100644 packages/mobile-ui-vue/components/list/src/composition/types.ts create mode 100644 packages/mobile-ui-vue/components/list/src/composition/use-scroll-reach-edge-check.ts create mode 100644 packages/mobile-ui-vue/components/list/src/list.component.tsx create mode 100644 packages/mobile-ui-vue/components/list/src/list.props.ts create mode 100644 packages/mobile-ui-vue/components/list/src/list.scss create mode 100644 packages/mobile-ui-vue/demos/list/base.vue create mode 100644 packages/mobile-ui-vue/demos/list/error.vue create mode 100644 packages/mobile-ui-vue/demos/list/index.vue create mode 100644 packages/mobile-ui-vue/demos/list/manual.vue diff --git a/packages/mobile-ui-vue/components/hook/index.ts b/packages/mobile-ui-vue/components/hook/index.ts index 9fd82fd174..52433a167f 100644 --- a/packages/mobile-ui-vue/components/hook/index.ts +++ b/packages/mobile-ui-vue/components/hook/index.ts @@ -11,3 +11,4 @@ export * from './use-click-away'; export * from './use-lock-scroll'; export * from './use-momentum'; export * from './use-scroll-parent'; +export * from './use-resize-observer'; diff --git a/packages/mobile-ui-vue/components/hook/use-resize-observer/index.ts b/packages/mobile-ui-vue/components/hook/use-resize-observer/index.ts new file mode 100644 index 0000000000..ba5098acf2 --- /dev/null +++ b/packages/mobile-ui-vue/components/hook/use-resize-observer/index.ts @@ -0,0 +1,85 @@ +/* eslint-disable no-use-before-define */ +import { computed, watch } from 'vue'; +import { MaybeComputedElementRef, unrefElement, tryOnScopeDispose, defaultWindow } from './utils'; + +export interface ResizeObserverSize { + readonly inlineSize: number; + readonly blockSize: number; +} + +export interface ResizeObserverEntry { + readonly target: Element; + readonly contentRect: DOMRectReadOnly; + readonly borderBoxSize?: ReadonlyArray; + readonly contentBoxSize?: ReadonlyArray; + readonly devicePixelContentBoxSize?: ReadonlyArray; +} + +export type ResizeObserverCallback = (entries: ReadonlyArray, observer: ResizeObserver) => void; + +export interface UseResizeObserverOptions { + + /** 监听的盒模型,默认为`content-box` */ + box?: ResizeObserverBoxOptions; + + /** 允许指定一个的`window` */ + window?: Window; +} + +declare class ResizeObserver { + + constructor(callback: ResizeObserverCallback); + + disconnect(): void; + + observe(target: Element, options?: UseResizeObserverOptions): void; + + unobserve(target: Element): void; +} + +/** + * 监听元素的大小变化 + */ +export function useResizeObserver( + target: MaybeComputedElementRef | MaybeComputedElementRef[], + callback: ResizeObserverCallback, + options: UseResizeObserverOptions = {}, +) { + const { window = defaultWindow, ...observerOptions } = options; + let observer: ResizeObserver | undefined; + + const cleanup = () => { + if (observer) { + observer.disconnect(); + observer = undefined; + } + }; + + const targets = computed(() => + Array.isArray(target) + ? target.map(el => unrefElement(el)) + : [unrefElement(target)]); + + const stopWatch = watch( + targets, + (elements) => { + cleanup(); + if (window && 'ResizeObserver' in window) { + observer = new ResizeObserver(callback); + elements.forEach((element) => { + element && observer!.observe(element, observerOptions); + }); + } + }, + { immediate: true, flush: 'post' }, + ); + + const stop = () => { + cleanup(); + stopWatch(); + }; + + tryOnScopeDispose(stop); + + return { stop }; +} diff --git a/packages/mobile-ui-vue/components/hook/use-resize-observer/utils.ts b/packages/mobile-ui-vue/components/hook/use-resize-observer/utils.ts new file mode 100644 index 0000000000..327cadae00 --- /dev/null +++ b/packages/mobile-ui-vue/components/hook/use-resize-observer/utils.ts @@ -0,0 +1,37 @@ +/* eslint-disable no-use-before-define */ +import { ComponentPublicInstance, Ref, unref, getCurrentScope, onScopeDispose } from 'vue'; +import { inBrowser } from '../../utils'; + +export const defaultWindow = inBrowser ? window : undefined; + +export type MaybeRef = T | Ref; +export type MaybeRefOrGetter = MaybeRef | (() => T); + +export type VueInstance = ComponentPublicInstance; +export type MaybeElementRef = MaybeRef; +export type MaybeComputedElementRef = MaybeRefOrGetter; +export type MaybeElement = HTMLElement | SVGElement | VueInstance | undefined | null; + +export type UnRefElementReturn = + T extends VueInstance ? Exclude : T | undefined; + +export type AnyFn = (...args: any[]) => any; + +export function toValue(r: MaybeRefOrGetter): T { + return typeof r === 'function' + ? (r as AnyFn)() + : unref(r); +} + +export function unrefElement(elRef: MaybeComputedElementRef): UnRefElementReturn { + const plain = toValue(elRef); + return (plain as VueInstance)?.$el ?? plain; +} + +export function tryOnScopeDispose(fn: (() => void)) { + if (getCurrentScope()) { + onScopeDispose(fn); + return true; + } + return false; +} diff --git a/packages/mobile-ui-vue/components/index.scss b/packages/mobile-ui-vue/components/index.scss index 5bcd8a6445..2d49f87fc8 100644 --- a/packages/mobile-ui-vue/components/index.scss +++ b/packages/mobile-ui-vue/components/index.scss @@ -12,3 +12,4 @@ @import './popup/src/popup.scss'; @import './loading/src/loading.scss'; @import './pull-refresh/src/pull-refresh.scss'; +@import './list/src/list.scss'; diff --git a/packages/mobile-ui-vue/components/index.ts b/packages/mobile-ui-vue/components/index.ts index d896f82f99..61481de493 100644 --- a/packages/mobile-ui-vue/components/index.ts +++ b/packages/mobile-ui-vue/components/index.ts @@ -30,6 +30,7 @@ import Overlay from './overlay'; import { Picker, PickerForm } from './picker'; import Loading from './loading'; import PullRefresh from './pull-refresh'; +import List from './list'; import './index.scss'; @@ -53,6 +54,7 @@ const components = [ PickerForm, Loading, PullRefresh, + List, Overlay ]; @@ -80,6 +82,7 @@ export { Popup, Loading, PullRefresh, + List, Overlay }; diff --git a/packages/mobile-ui-vue/components/list/index.ts b/packages/mobile-ui-vue/components/list/index.ts new file mode 100644 index 0000000000..282cd14158 --- /dev/null +++ b/packages/mobile-ui-vue/components/list/index.ts @@ -0,0 +1,9 @@ +import { withInstall } from '../utils'; +import _List from './src/list.component'; + +export * from './src/list.props'; + +const List = withInstall(_List); + +export { List }; +export default List; diff --git a/packages/mobile-ui-vue/components/list/src/composition/types.ts b/packages/mobile-ui-vue/components/list/src/composition/types.ts new file mode 100644 index 0000000000..73b6a8cd34 --- /dev/null +++ b/packages/mobile-ui-vue/components/list/src/composition/types.ts @@ -0,0 +1,27 @@ +import type { Ref } from 'vue'; + +/** + * 列表方向 + * @description 如果为`down`,则滑动到容器底部时触发加载事件 + */ +export type ListDirection = "up" | "down"; + +type MaybeRef = T | Ref; + +export interface UseScrollReachEdgeCheckOptions { + + containerRef: Ref; + + direction?: MaybeRef; + + offset?: MaybeRef; + + onScroll?: (payload: { topDistance: number; bottomDistance: number }) => void; +} + +export interface UseScrollReachEdgeCheck { + + isReachEdge: Ref; + + check: () => void; +} diff --git a/packages/mobile-ui-vue/components/list/src/composition/use-scroll-reach-edge-check.ts b/packages/mobile-ui-vue/components/list/src/composition/use-scroll-reach-edge-check.ts new file mode 100644 index 0000000000..023cc015f0 --- /dev/null +++ b/packages/mobile-ui-vue/components/list/src/composition/use-scroll-reach-edge-check.ts @@ -0,0 +1,86 @@ +import { nextTick, onMounted, Ref, ref, isRef, computed } from 'vue'; +import type { ListDirection, UseScrollReachEdgeCheckOptions, UseScrollReachEdgeCheck } from './types'; +import { useScrollParent, useEventListener, useResizeObserver } from '@/components/hook'; +import { inBrowser, isNumber } from '@/components/utils'; + +const DEFAULT_OFFSET = 30; + +export const useScrollReachEdgeCheck = ( + options: UseScrollReachEdgeCheckOptions, + listener?: () => void, +): UseScrollReachEdgeCheck => { + const { containerRef, direction, offset } = options; + const isReachEdge = ref(false); + const scrollerRef: Ref = useScrollParent(containerRef); + + const directionRef = computed(() => { + const value = isRef(direction) ? direction.value : direction; + return value || 'down'; + }); + + const offsetRef = computed(() => { + const value = isRef(offset) ? +offset.value : +offset; + return value >= 0 ? value : DEFAULT_OFFSET; + }); + + const check = (e?: Event) => { + nextTick(() => { + const target = (e && e.target || scrollerRef.value) as HTMLElement; + if (!target) { + return; + } + const windowHeight = inBrowser ? window.innerHeight : 0; + + const scrollHeight = isNumber(target.scrollHeight) + ? target.scrollHeight + : Math.max(document.documentElement.scrollHeight, document.body.scrollHeight); + + const offsetHeight = isNumber(target.offsetHeight) + ? target.offsetHeight + : windowHeight; + + const topDistance = isNumber(target.scrollTop) + ? target.scrollTop + : (document.documentElement.scrollTop || document.body.scrollTop); + + // 如果容器没有高度,则不进行处理 + if (offsetHeight <= 0) { + return; + } + + const bottomDistance = scrollHeight - topDistance - offsetHeight; + + isReachEdge.value = directionRef.value === 'up' ? (topDistance <= offsetRef.value) : (bottomDistance <= offsetRef.value); + isReachEdge.value && listener && listener(); + + if (options.onScroll) { + options.onScroll({ topDistance, bottomDistance }); + } + }); + }; + + const resizeObservableElRef = computed(() => { + const scroller = scrollerRef.value; + if (!scroller || scroller instanceof Window || scroller.nodeType !== 1) { + return document.body; + } + return scrollerRef.value as HTMLElement; + }); + + useResizeObserver(resizeObservableElRef, () => { + check(); + }); + + onMounted(() => { + useEventListener('scroll', check, { + target: scrollerRef, + passive: true, + immediate: true, + }); + }); + + return { + isReachEdge, + check: () => check(), + }; +}; diff --git a/packages/mobile-ui-vue/components/list/src/list.component.tsx b/packages/mobile-ui-vue/components/list/src/list.component.tsx new file mode 100644 index 0000000000..73e4ff02d7 --- /dev/null +++ b/packages/mobile-ui-vue/components/list/src/list.component.tsx @@ -0,0 +1,192 @@ +/** + * Copyright (c) 2020 - present, Inspur Genersoft Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { defineComponent, SetupContext, ref, watch, onMounted, toRefs, computed } from 'vue'; +import { listProps, ListProps } from './list.props'; +import { useScrollReachEdgeCheck } from './composition/use-scroll-reach-edge-check'; +import { useBem } from '@/components/hook'; +import Loading from '@/components/loading'; + +export default defineComponent({ + name: 'FmList', + + props: listProps, + + emit: ['load', 'update:error', 'update:loading', 'scroll'], + + setup(props: ListProps, context: SetupContext) { + const blockName = 'fm-list'; + const { bem } = useBem(blockName); + const { emit, slots, expose } = context; + const root = ref(); + const innerLoading = ref(props.loading); + + const loadMore = () => { + innerLoading.value = true; + emit('update:loading', true); + emit('load'); + }; + + const canAutoLoadMore = computed(() => { + return props.autoLoadMore + && !innerLoading.value + && !props.finished + && !props.disabled + && !props.error; + }); + + const onReachEdge = () => { + if (canAutoLoadMore.value) { + loadMore(); + } + }; + + const { + direction: listDirection, + offset: triggerLoadOffset, + } = toRefs(props); + + const { check } = useScrollReachEdgeCheck( + { + containerRef: root, + direction: listDirection.value, + offset: triggerLoadOffset.value, + onScroll: (payload) => { emit('scroll', payload); }, + }, + onReachEdge, + ); + + watch(() => [props.loading, props.finished, props.error], () => { + innerLoading.value = props.loading; + if (props.autoLoadMore) { + check(); + } + }); + + const needImmediateCheck = computed(() => props.autoLoadMore && props.immediateCheck); + + onMounted(() => { + if (needImmediateCheck.value) { + check(); + } + }); + + const onLoadMore = () => { + if (props.disabled) { + return; + } + if (props.error) { + emit('update:error', false); + } + loadMore(); + }; + + const renderHeader = () => { + const header = slots.header?.(); + if (header) { + return ( +
{header}
+ ); + } + }; + + const renderFooter = () => { + const footer = slots.footer?.(); + if (footer) { + return ( +
{footer}
+ ); + } + }; + + const renderFinishedInfo = () => { + if (!props.showFinishedTip) { + return; + } + const text = slots.finished ? slots.finished() : props.finishedText; + if (text) { + return
{text}
; + } + }; + + const renderLoading = () => { + const loading = slots.loading ? slots.loading() : ( +
+ +
+ ); + return ( +
{loading}
+ ); + }; + + const renderErrorInfo = () => { + const text = slots.error ? slots.error() : props.errorText; + if (text) { + return ( +
{text}
+ ); + } + }; + + const renderLoadMoreButton = () => { + const text = slots.loadMore ? slots.loadMore() : props.loadMoreText || '加载更多'; + return ( +
{text}
+ ); + }; + + const shouldShowFinishedInfo = computed(() => props.finished); + const shouldShowLoading = computed(() => { + return innerLoading.value && !props.disabled && !shouldShowFinishedInfo.value; + }); + const shouldShowErrorInfo = computed(() => { + return props.error && !shouldShowFinishedInfo.value && !shouldShowLoading.value; + }); + const shouldShowLoadMoreButton = computed(() => { + return !props.autoLoadMore && !shouldShowFinishedInfo.value && !shouldShowLoading.value && !shouldShowErrorInfo.value; + }); + + const renderStatusBar = () => { + return ( + <> + {shouldShowFinishedInfo.value && renderFinishedInfo()} + {shouldShowLoading.value && renderLoading()} + {shouldShowErrorInfo.value && renderErrorInfo()} + {shouldShowLoadMoreButton.value && renderLoadMoreButton()} + + ); + }; + + expose({ check }); + + return () => { + const content = slots.default?.(); + const shouldShowContentAboveStatus = listDirection.value === 'down'; + const shouldShowContentBelowStatus = listDirection.value === 'up'; + + return ( +
+ {renderHeader()} + {shouldShowContentAboveStatus && content} + {renderStatusBar()} + {shouldShowContentBelowStatus && content} + {renderFooter()} +
+ ); + }; + } + +}); diff --git a/packages/mobile-ui-vue/components/list/src/list.props.ts b/packages/mobile-ui-vue/components/list/src/list.props.ts new file mode 100644 index 0000000000..cf0ec2c073 --- /dev/null +++ b/packages/mobile-ui-vue/components/list/src/list.props.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2020 - present, Inspur Genersoft Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ExtractPropTypes, PropType } from 'vue'; +import { ListDirection } from './composition/types'; + +export const listProps = { + + /** 是否处于加载状态,支持语法糖`v-model:loading`,此状态下将在列表底部显示加载中提示文本 */ + loading: { type: Boolean, default: false }, + + /** 是否处于加载失败状态,支持语法糖`v-model:error`,此状态下将在列表底部显示加载失败提示文本,用户点击提示将触发加载事件 */ + error: { type: Boolean, default: false }, + + /** 是否加载完成,加载完成后将在列表下方显示加载完成提示文本,且不会再触发加载事件 */ + finished: { type: Boolean, default: false }, + + /** 是否自动触发加载事件,默认为`true`,当用户滑动到列表底部时,自动触发加载事件 */ + autoLoadMore: { type: Boolean, default: true }, + + /** 滚动条与底部的距离小于等于`offset`(且`autoLoadMore=true`)时自动触发加载事件 */ + offset: { type: [Number, String], default: 30 }, + + /** 是否在初始化时立即检查自动触发加载事件的条件,满足则自动触发加载事件 */ + immediateCheck: { type: Boolean, default: true }, + + /** 是否禁用,禁用后不再触发加载事件 */ + disabled: { type: Boolean, default: false }, + + /** 默认情况下滚动到底部触发加载,如果将本属性设为`up`则滚动到顶部时触发加载 */ + direction: { type: String as PropType, default: 'down' }, + + /** 加载更多提示文本 */ + loadMoreText: { type: String, default: undefined }, + + /** 加载中提示文本 */ + loadingText: { type: String, default: undefined }, + + /** 加载完成提示文本 */ + finishedText: { type: String, default: undefined }, + + /** 加载失败提示文本 */ + errorText: { type: String, default: undefined }, + + /** 是否显示加载完成提示文本,如果为`false`则忽略`finishedText`属性和`finished`插槽 */ + showFinishedTip: { type: Boolean, default: true }, + +} as Record; + +export type ListProps = ExtractPropTypes; diff --git a/packages/mobile-ui-vue/components/list/src/list.scss b/packages/mobile-ui-vue/components/list/src/list.scss new file mode 100644 index 0000000000..db91752f24 --- /dev/null +++ b/packages/mobile-ui-vue/components/list/src/list.scss @@ -0,0 +1,26 @@ +:root, +:host { + --fm-list-text-color: var(--fm-gray-5); + --fm-list-text-font-size: var(--fm-font-size-md, 14px); + --fm-list-text-line-height: 50px; + --fm-list-loading-icon-size: 16px; +} + +.fm-list { + &__loading, + &__error, + &__load-more, + &__finished-info { + color: var(--fm-list-text-color); + font-size: var(--fm-list-text-font-size); + line-height: var(--fm-list-text-line-height); + text-align: center; + } + + &__loading-container { + display: flex; + height: var(--fm-list-text-line-height); + justify-content: center; + align-items: center; + } +} diff --git a/packages/mobile-ui-vue/demos/list/base.vue b/packages/mobile-ui-vue/demos/list/base.vue new file mode 100644 index 0000000000..c952c5a100 --- /dev/null +++ b/packages/mobile-ui-vue/demos/list/base.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/packages/mobile-ui-vue/demos/list/error.vue b/packages/mobile-ui-vue/demos/list/error.vue new file mode 100644 index 0000000000..b748425e1e --- /dev/null +++ b/packages/mobile-ui-vue/demos/list/error.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/packages/mobile-ui-vue/demos/list/index.vue b/packages/mobile-ui-vue/demos/list/index.vue new file mode 100644 index 0000000000..5abb792a46 --- /dev/null +++ b/packages/mobile-ui-vue/demos/list/index.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/packages/mobile-ui-vue/demos/list/manual.vue b/packages/mobile-ui-vue/demos/list/manual.vue new file mode 100644 index 0000000000..cb5c5b272e --- /dev/null +++ b/packages/mobile-ui-vue/demos/list/manual.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/packages/mobile-ui-vue/src/menu-data.ts b/packages/mobile-ui-vue/src/menu-data.ts index c6b6c1ebc7..bbcc99020e 100644 --- a/packages/mobile-ui-vue/src/menu-data.ts +++ b/packages/mobile-ui-vue/src/menu-data.ts @@ -99,5 +99,16 @@ export default { }, ], }, + { + title: "展示组件", + subMenu: [ + { + title: "列表", + name: "list", + url: "/demos/list", + component: "/list", + }, + ], + }, ] }; -- Gitee