diff --git a/site/mobile/components/style/index.less b/site/mobile/components/style/index.less index 10e578a0..fbe4680f 100644 --- a/site/mobile/components/style/index.less +++ b/site/mobile/components/style/index.less @@ -6,10 +6,12 @@ #app { min-height: 100vh; + height: 100vh; display: flex; flex-direction: column; } .tdesign-mobile-demo { flex: 1; + overflow-y: scroll; } \ No newline at end of file diff --git a/site/mobile/mobile.config.js b/site/mobile/mobile.config.js index d8fcfdec..556fb44d 100644 --- a/site/mobile/mobile.config.js +++ b/site/mobile/mobile.config.js @@ -35,6 +35,11 @@ export default { name: 'grid', component: () => import('tdesign-mobile-react/grid/_example/index.tsx'), }, + { + title: 'List 列表', + name: 'list', + component: () => import('tdesign-mobile-react/list/_example/index.jsx'), + }, { title: 'Image 图片', name: 'image', diff --git a/site/web/site.config.js b/site/web/site.config.js index ecac7822..614bf747 100644 --- a/site/web/site.config.js +++ b/site/web/site.config.js @@ -262,12 +262,12 @@ export default { path: '/mobile-react/components/image', component: () => import('tdesign-mobile-react/image/image.md'), }, - // { - // title: 'List 列表', - // name: 'list', - // path: '/mobile-react/components/list', - // component: () => import('tdesign-mobile-react/list/list.md'), - // }, + { + title: 'List 列表', + name: 'list', + path: '/mobile-react/components/list', + component: () => import('tdesign-mobile-react/list/list.md'), + }, // { // title: 'ImageViewer 图片预览', // name: 'image-viewer', diff --git a/src/_common b/src/_common index f9683c1e..05c1f59e 160000 --- a/src/_common +++ b/src/_common @@ -1 +1 @@ -Subproject commit f9683c1edee5c09f455ba8cb19cb8ce07d4490be +Subproject commit 05c1f59ef27bea7da2fcfd09f98b0b838516d538 diff --git a/src/hooks/useWindowHeight.ts b/src/hooks/useWindowHeight.ts new file mode 100644 index 00000000..3cc85cdb --- /dev/null +++ b/src/hooks/useWindowHeight.ts @@ -0,0 +1,12 @@ +import { useState } from 'react'; + +const useWindowHeight = () => { + const [height, setHeight] = useState(window.innerHeight); + window.onresize = () => { + const height = window.innerHeight; + setHeight(height); + }; + return height; +}; + +export default useWindowHeight; diff --git a/src/index.ts b/src/index.ts index 678cb0fa..30ad9fb6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,8 @@ export * from './sticky'; export * from './swiper'; export * from './swipe-cell'; export * from './tag'; + +export * from './list'; export * from './result'; export * from './table'; export * from './empty'; diff --git a/src/list/_example/base.tsx b/src/list/_example/base.tsx new file mode 100644 index 00000000..30f3cec9 --- /dev/null +++ b/src/list/_example/base.tsx @@ -0,0 +1,66 @@ +import React, { useEffect, useState, useRef } from 'react'; +import './style/index.less'; +import { Cell, List } from 'tdesign-mobile-react'; + +interface ListItem { + id: number; + content: string; + icon: string; + title: string; +} + +export default function ListDemo() { + const [isLoading, setIsLoading] = useState(false); + const pageSize = 20; + const stateRef = useRef([]); + const pageRef = useRef(1); + const dataSource: ListItem[] = []; + const total = 100; + for (let i = 0; i < total; i++) { + dataSource.push({ + id: i, + content: '列表内容列表内容列表内容', + icon: 'https://tdesign.gtimg.com/list-icon.png', + title: '列表主内容', + }); + } + + // 模拟请求 + const fetchData = async (pageInfo) => { + if (isLoading) return; + setIsLoading(true); + try { + setTimeout(() => { + const { pageNum, pageSize } = pageInfo; + const newDataSource = dataSource.slice((pageNum - 1) * pageSize, pageNum * pageSize); + const newListData = stateRef.current.concat(newDataSource); + pageRef.current = pageNum; + stateRef.current = newListData; + setIsLoading(false); + }, 0); + } catch (err) { + stateRef.current = []; + } + }; + + const onScroll = (scrollBottom) => { + if (!scrollBottom && stateRef.current.length < total) { + fetchData({ pageNum: pageRef.current + 1, pageSize }); + } + }; + + useEffect(() => { + fetchData({ pageNum: pageRef.current, pageSize }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {stateRef.current.map((item) => ( + + {item.id} + + ))} + + ); +} diff --git a/src/list/_example/err-tip.tsx b/src/list/_example/err-tip.tsx new file mode 100644 index 00000000..4a30637c --- /dev/null +++ b/src/list/_example/err-tip.tsx @@ -0,0 +1,64 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Cell, List, Loading } from 'tdesign-mobile-react'; + +export default function ListDemo() { + const listError = useRef([]); + const [loading, setLoading] = useState(''); + const [showError, setShowError] = useState(false); + + const onLoadError = () => { + setLoading('loading'); + + setTimeout(() => { + const newVal: string[] = [...listError.current]; + for (let i = listError.current.length; i < 8; i++) { + newVal.push(`${i}`); + } + listError.current = newVal; + + setShowError(true); + setLoading(''); + }, 1000); + }; + + const onLoadMore = () => { + setShowError(false); + if (listError.current.length >= 60 || loading) { + return; + } + setLoading('loading'); + + setTimeout(() => { + for (let i = 0; i < 15; i++) { + listError.current.push(`${listError.current.length + 1}`); + } + setLoading(''); + }, 1000); + }; + + useEffect(() => { + onLoadError(); + }, []); + + return ( + + + 请求失败,点击重新加载 + + + ) + } + > + {listError.current.map((item) => ( + + {item} + + ))} + + ); +} diff --git a/src/list/_example/index.tsx b/src/list/_example/index.tsx new file mode 100644 index 00000000..a7ed93ce --- /dev/null +++ b/src/list/_example/index.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; +import { Button } from 'tdesign-mobile-react'; +import TDemoBlock from '../../../site/mobile/components/DemoBlock'; +// import TDemoHeader from '../../../site/mobile/components/DemoHeader'; +import './style/index.less'; + +import BaseList from './base.jsx'; +import ErrTipDemo from './err-tip.jsx'; +import PullRefreshDemo from './pull-refresh.jsx'; + +export default function ListDemo() { + const [currentTab, setCurrentTab] = useState('info'); + + const onChangeTab = (val) => { + setCurrentTab(val); + history.pushState({}, '', '?tab=demo'); + }; + + return ( + + + {currentTab === 'info' && ( + + List 列表 + + 瀑布流滚动加载,用于展示同一类型信息的长列表。当列表即将滚动到底部时,会触发事件并加载更多列表项。 + + + onChangeTab('base')}> + {' '} + 基础列表{' '} + + onChangeTab('pull-refresh')}> + 下拉刷新 + + onChangeTab('error-tip')}> + 错误提示 + + + + )} + {currentTab === 'base' && } + {currentTab === 'error-tip' && } + {currentTab === 'pull-refresh' && ( + + + + )} + + + ); +} diff --git a/src/list/_example/pull-refresh.tsx b/src/list/_example/pull-refresh.tsx new file mode 100644 index 00000000..5ff17987 --- /dev/null +++ b/src/list/_example/pull-refresh.tsx @@ -0,0 +1,71 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Cell, List, PullDownRefresh } from 'tdesign-mobile-react'; + +export default function ListDemo() { + const [loading, setLoading] = useState(''); + const [refreshing, setRefreshing] = useState(false); + + const listData = useRef([]); + + const MAX_DATA_LEN = 60; + + const loadData = (isRefresh) => { + const ONCE_LOAD_NUM = 20; + return new Promise(() => { + setTimeout(() => { + const temp: string[] = []; + for (let i = 0; i < ONCE_LOAD_NUM; i++) { + if (isRefresh) { + temp.push(`${i + 1}`); + } else { + temp.push(`${listData.current.length + 1 + i}`); + } + } + + if (isRefresh) { + listData.current = temp; + } else { + listData.current = [...listData.current, ...temp]; + } + setLoading(''); + setRefreshing(false); + }, 1000); + }); + }; + + const onLoadData = (isRefresh?) => { + if ((listData.current.length >= MAX_DATA_LEN && !isRefresh) || loading.value) { + return; + } + setLoading('loading'); + loadData(isRefresh); + }; + + const onScroll = (scrollBottom) => { + if (scrollBottom < 50) { + onLoadData(); + } + }; + + const onRefresh = () => { + setRefreshing(true); + onLoadData(true); + }; + + useEffect(() => { + onLoadData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + setRefreshing(val)} onRefresh={onRefresh}> + + {listData.current.map((item) => ( + + {item} + + ))} + + + ); +} diff --git a/src/list/_example/style/index.less b/src/list/_example/style/index.less new file mode 100644 index 00000000..e1a5a2b8 --- /dev/null +++ b/src/list/_example/style/index.less @@ -0,0 +1,30 @@ +.list-demo { + .t-list { + .cell { + width: 100%; + text-align: center; + } + .error { + text-align: center; + color: #969799; + font-size: 14px; + margin-top: 8px; + } + } + .custom-error { + font-size: 14px; + color: #969799; + text-align: center; + padding-top: 16px; + cursor: default; + + span { + color: #0052d9; + cursor: pointer; + } + } + .t-button { + margin: 0 16px 16px 16px; + width: calc(100% - 32px); + } +} \ No newline at end of file diff --git a/src/list/index.ts b/src/list/index.ts new file mode 100644 index 00000000..26d5e2a0 --- /dev/null +++ b/src/list/index.ts @@ -0,0 +1,8 @@ +import _List from './list'; + +import './style/index.js'; + +export * from './type'; + +export const List = _List; +export default List; diff --git a/src/list/list.en-US.md b/src/list/list.en-US.md new file mode 100644 index 00000000..ca82cafa --- /dev/null +++ b/src/list/list.en-US.md @@ -0,0 +1,13 @@ +:: BASE_DOC :: + +## API + +### List Props + +name | type | default | description | required +-- | -- | -- | -- | -- +asyncLoading | TNode / Function | - | Typescript:`string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N +footer | TNode / Function | - | Typescript:`string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N +header | TNode / Function | - | Typescript:`string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N +onLoadMore | Function | | Typescript:`() => void` | N +onScroll | Function | | Typescript:`(bottomDistance: number, scrollTop: number) => void` | N \ No newline at end of file diff --git a/src/list/list.md b/src/list/list.md new file mode 100644 index 00000000..62ed0905 --- /dev/null +++ b/src/list/list.md @@ -0,0 +1,13 @@ +:: BASE_DOC :: + +## API + +### List Props + +名称 | 类型 | 默认值 | 描述 | 必传 +-- | -- | -- | -- | -- +asyncLoading | String / TNode | - | 自定义加载中。值为空不显示加载中,值为 'loading' 显示加载中状态,值为 'load-more' 显示加载更多状态。值类型为函数,则表示自定义加载状态呈现内容。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N +footer | String / TNode | - | 底部。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N +header | String / TNode | - | 头部。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N +onLoadMore | Function | | TS 类型:`() => void`点击加载更多时触发 | N +onScroll | Function | | TS 类型:`(bottomDistance: number, scrollTop: number) => void`列表滚动时触发,bottomDistance 表示底部距离;scrollTop 表示顶部滚动距离 | N diff --git a/src/list/list.tsx b/src/list/list.tsx new file mode 100644 index 00000000..63802f8a --- /dev/null +++ b/src/list/list.tsx @@ -0,0 +1,76 @@ +import React, { useRef, useEffect, useCallback } from 'react'; +import { TdListProps } from './type'; +import useConfig from '../_util/useConfig'; + +import TLoading from '../loading'; +import parseTNode from '../_util/parseTNode'; +import getScrollParent from '../_util/getScrollParent'; +import useWindowHeight from '../hooks/useWindowHeight'; + +export interface ListProps extends TdListProps { + required?: boolean; + readonly?: boolean; +} + +const List: React.FC = (props) => { + const { classPrefix } = useConfig(); + const { asyncLoading, header, footer, children } = props; + const name = classPrefix; + + const LOADING_TEXT_MAP = { + loading: '加载中', // TODO: i18n + 'load-more': '加载更多', + }; + + const root = useRef(null); + + const height = useWindowHeight(); + + const onLoadMore = () => { + if (asyncLoading === 'load-more') { + props.onLoadMore?.(); + } + }; + + const handleScroll = useCallback((e: WheelEvent | Event): void => { + const scrollHeight = + (e.target as HTMLElement).scrollHeight || + Math.max(document.documentElement.scrollHeight, document.body.scrollHeight); + + const scrollTop = + (e.target as HTMLElement).scrollTop || document.documentElement.scrollTop || document.body.scrollTop; + + const offsetHeight = (e.target as HTMLElement).offsetHeight || height; + const bottomDistance = scrollHeight - (scrollTop + offsetHeight); + props.onScroll?.(bottomDistance, scrollTop); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const scorllParent = getScrollParent(root.current); + if (scorllParent === root.current) return; + scorllParent.addEventListener('scroll', handleScroll); + return () => { + removeEventListener('scroll', handleScroll); + }; + }, [height, handleScroll]); + + return ( + handleScroll(e)}> + {parseTNode(header)} + {parseTNode(children)} + onLoadMore()}> + {typeof props.asyncLoading === 'string' && ['loading', 'load-more'].includes(props.asyncLoading) && ( + + )} + + {parseTNode(footer)} + + ); +}; + +export default List; diff --git a/src/list/style/css.js b/src/list/style/css.js new file mode 100644 index 00000000..6a9a4b13 --- /dev/null +++ b/src/list/style/css.js @@ -0,0 +1 @@ +import './index.css'; diff --git a/src/list/style/index.js b/src/list/style/index.js new file mode 100644 index 00000000..fdbefc78 --- /dev/null +++ b/src/list/style/index.js @@ -0,0 +1 @@ +import '../../_common/style/mobile/components/list/_index.less'; diff --git a/src/list/type.ts b/src/list/type.ts new file mode 100644 index 00000000..52be6029 --- /dev/null +++ b/src/list/type.ts @@ -0,0 +1,34 @@ +/* eslint-disable */ + +/** + * 该文件为脚本自动生成文件,请勿随意修改。如需修改请联系 PMC + * */ + +import { TNode } from '../common'; + +export interface TdListProps { + /** + * 自定义加载中。值为空不显示加载中,值为 'loading' 显示加载中状态,值为 'load-more' 显示加载更多状态。值类型为函数,则表示自定义加载状态呈现内容 + */ + asyncLoading?: string | TNode; + /** + * 底部 + */ + footer?: string | TNode; + /** + * 子元素 + */ + children?: string | TNode; + /** + * 头部 + */ + header?: string | TNode; + /** + * 点击加载更多时触发 + */ + onLoadMore?: () => void; + /** + * 列表滚动时触发,bottomDistance 表示底部距离;scrollTop 表示顶部滚动距离 + */ + onScroll?: (bottomDistance: number, scrollTop: number) => void; +} diff --git a/src/result/Result.tsx b/src/result/Result.tsx index bb8235d3..a805a62a 100644 --- a/src/result/Result.tsx +++ b/src/result/Result.tsx @@ -7,8 +7,7 @@ import withNativeProps, { NativeProps } from '../_util/withNativeProps'; import useConfig from '../_util/useConfig'; import useDefaultProps from '../hooks/useDefaultProps'; -export interface ResultProps extends TdResultProps, NativeProps { -} +export interface ResultProps extends TdResultProps, NativeProps {} const Result: React.FC = (props) => { const { description, image, theme, title } = useDefaultProps(props, resultDefaultProps); diff --git a/src/sticky/style/index.js b/src/sticky/style/index.js index 531cf765..f75b026a 100644 --- a/src/sticky/style/index.js +++ b/src/sticky/style/index.js @@ -1 +1 @@ -import '../../_common/style/mobile/components/sticky/_index.less'; \ No newline at end of file +import '../../_common/style/mobile/components/sticky/_index.less';
+ 瀑布流滚动加载,用于展示同一类型信息的长列表。当列表即将滚动到底部时,会触发事件并加载更多列表项。 +