From 31e451739f980866308e8910c18725c7005fde00 Mon Sep 17 00:00:00 2001 From: Lee Hon Date: Fri, 22 Jan 2021 13:26:35 +0800 Subject: [PATCH] feat(layout): add layout component (#722) Co-authored-by: Dan --- src/components/layout/Layout.mdx | 27 +++++++ src/components/layout/Layout.stories.tsx | 76 ++++++++++++++++++ .../layout/__tests__/Layout.test.tsx | 71 +++++++++++++++++ .../__snapshots__/Layout.test.tsx.snap | 27 +++++++ src/components/layout/content.tsx | 41 ++++++++++ src/components/layout/header.tsx | 19 +++++ src/components/layout/index.ts | 4 + src/components/layout/interfaces.ts | 69 ++++++++++++++++ src/components/layout/layout.tsx | 61 +++++++++++++++ src/components/layout/sider.tsx | 78 +++++++++++++++++++ src/components/layout/style/index.less | 46 +++++++++++ src/components/layout/style/index.ts | 1 + src/components/layout/useSiders.ts | 39 ++++++++++ 13 files changed, 559 insertions(+) create mode 100644 src/components/layout/Layout.mdx create mode 100644 src/components/layout/Layout.stories.tsx create mode 100644 src/components/layout/__tests__/Layout.test.tsx create mode 100644 src/components/layout/__tests__/__snapshots__/Layout.test.tsx.snap create mode 100644 src/components/layout/content.tsx create mode 100644 src/components/layout/header.tsx create mode 100644 src/components/layout/index.ts create mode 100644 src/components/layout/interfaces.ts create mode 100644 src/components/layout/layout.tsx create mode 100644 src/components/layout/sider.tsx create mode 100644 src/components/layout/style/index.less create mode 100644 src/components/layout/style/index.ts create mode 100644 src/components/layout/useSiders.ts diff --git a/src/components/layout/Layout.mdx b/src/components/layout/Layout.mdx new file mode 100644 index 0000000000..57244fe1ef --- /dev/null +++ b/src/components/layout/Layout.mdx @@ -0,0 +1,27 @@ +import { Subtitle, ArgsTable } from '@storybook/addon-docs/blocks'; +import Layout from './index'; + +# Layout 布局 + +协助进行页面级整体布局。 + +## 参数说明 + + + +## 代码演示 + +### 样式 - 无侧导航页面 +[Example](/?path=/story/basic-components-layout--default) + +### 样式 - 收缩侧导航页面 + +[Example](/?path=/story/basic-components-layout--sider) + +### 样式 - 悬浮式展开侧导航 + +[Example](/?path=/story/basic-components-layout--suspend) + +### 样式 - 嵌入式展开侧导航 + +[Example](/?path=/story/basic-components-layout--embed) diff --git a/src/components/layout/Layout.stories.tsx b/src/components/layout/Layout.stories.tsx new file mode 100644 index 0000000000..3944da8cb7 --- /dev/null +++ b/src/components/layout/Layout.stories.tsx @@ -0,0 +1,76 @@ +import React, { useState } from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; +import Docs from './Layout.mdx'; +import { LayoutProps } from './interfaces'; +import Layout from './index'; +import Button from '../button'; +import './style'; + +export default { + title: 'Basic Components/Layout', + component: Layout, + parameters: { + docs: { + page: Docs, + }, + }, +} as Meta; + +const Template: Story = (args) => ; + +export const Default = Template.bind({}); + +Default.args = { + children: ( + <> + + + + ), +}; + +export const Sider = Template.bind({}); + +Sider.args = { + children: ( + <> + + + + + + + ), +}; + +// eslint-disable-next-line react/jsx-props-no-spreading +const DemoListTemplate: Story = (args) =>
; + +export const Suspend = DemoListTemplate.bind({}); + +const SuspendDemo = ({ suspend }: { suspend?: 'left' | 'right' }) => { + const [collapsed, setCollapsed] = useState(false); + return ( + + + + + + + + + + ); +}; + +Suspend.args = { + children: , +}; + +export const Embed = DemoListTemplate.bind({}); + +Embed.args = { + children: , +}; diff --git a/src/components/layout/__tests__/Layout.test.tsx b/src/components/layout/__tests__/Layout.test.tsx new file mode 100644 index 0000000000..4318a66641 --- /dev/null +++ b/src/components/layout/__tests__/Layout.test.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import Layout from '../layout'; +import useSiders from '../useSiders'; + +describe('Testing Layout', () => { + it('should be stable', () => { + const { asFragment } = render( + + + + + + + + ); + expect(asFragment()).toMatchSnapshot(); + }); + + test('Content maxWidth', () => { + const { getByText } = render( + + content + + ); + const contentElement = getByText('content'); + expect(contentElement.style.getPropertyValue('--layout-content-maxWidth')).toBe('100%'); + expect(contentElement.style.getPropertyValue('--layout-content-grow')).toBe('1'); + expect(contentElement.style.getPropertyValue('margin')).toBe('0px 20px'); + }); + + test('Content margin', () => { + const { getByText } = render( + + + content + + + ); + expect(getByText('content').style.getPropertyValue('margin')).toBe('0px 10px'); + }); + + test('useSiders', () => { + const { result } = renderHook(() => useSiders()); + const _updateSiders = result.current[3]; + act(() => { + _updateSiders({ id: '1', width: 200, collapsedWidth: 80, suspendedPosition: 'left' }); + _updateSiders({ id: '2', width: 300, collapsedWidth: 100, suspendedPosition: 'right' }); + _updateSiders({ id: '3', width: 100, collapsedWidth: 50 }); + }); + const [siders, sidersWidth, removeSider, updateSiders, margin] = result.current; + expect(siders.length).toBe(3); + expect(sidersWidth).toBe(600); + expect(margin).toStrictEqual([80, 100]); + act(() => { + removeSider('1'); + }); + expect(result.current[0].length).toBe(2); + expect(result.current[1]).toBe(400); + expect(result.current[4]).toStrictEqual([0, 100]); + act(() => { + updateSiders({ id: '3', width: 100, collapsedWidth: 50, suspendedPosition: 'left' }); + }); + expect(result.current[4]).toStrictEqual([50, 100]); + act(() => { + removeSider('2'); + }); + expect(result.current[4]).toStrictEqual([50, 0]); + }); +}); diff --git a/src/components/layout/__tests__/__snapshots__/Layout.test.tsx.snap b/src/components/layout/__tests__/__snapshots__/Layout.test.tsx.snap new file mode 100644 index 0000000000..fddb25e466 --- /dev/null +++ b/src/components/layout/__tests__/__snapshots__/Layout.test.tsx.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing Layout should be stable 1`] = ` + +
+
+
+`; diff --git a/src/components/layout/content.tsx b/src/components/layout/content.tsx new file mode 100644 index 0000000000..f4081bd936 --- /dev/null +++ b/src/components/layout/content.tsx @@ -0,0 +1,41 @@ +import React, { useContext, useEffect, useMemo } from 'react'; +import classNames from 'classnames'; +import { isNumber } from 'lodash'; +import { LayoutContentProps } from './interfaces'; +import usePrefixCls from '../../utils/hooks/use-prefix-cls'; +import { LayoutContext } from './layout'; + +const Content = ({ + prefixCls: customizePrefixCls, + className, + style, + children, + maxWidth = 1200, + margin = 20 +}: LayoutContentProps) => { + + const { layoutState, setContentState } = useContext(LayoutContext); + useEffect(() => { + setContentState({ maxWidth: isNumber(maxWidth) ? maxWidth : 0, margin }); + }, [margin, maxWidth, setContentState]); + + const prefixCls = usePrefixCls('layout-content', customizePrefixCls); + + const mergedStyle: React.CSSProperties = useMemo(() => ({ + '--layout-content-maxWidth': maxWidth === 'auto' ? '100%' : `${maxWidth}px`, + '--layout-content-grow': maxWidth === 'auto' ? 1 : 0, + ...((maxWidth === 'auto' || !layoutState.wide) ? { margin: `0 ${margin}px`} : {}), + ...style + }), [maxWidth, layoutState.wide, margin, style]); + + return ( +
+ {children} +
+ ); +} + +export default Content; \ No newline at end of file diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx new file mode 100644 index 0000000000..0145259144 --- /dev/null +++ b/src/components/layout/header.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import classNames from 'classnames'; +import { LayoutProps } from './interfaces'; +import usePrefixCls from '../../utils/hooks/use-prefix-cls'; + +const Header = ({ + prefixCls: customizePrefixCls, + className, + style, + children +}: LayoutProps) => { + + const prefixCls = usePrefixCls('layout-header', customizePrefixCls); + return ( +
{children}
+ ); +} + +export default Header; \ No newline at end of file diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts new file mode 100644 index 0000000000..07ec302e5c --- /dev/null +++ b/src/components/layout/index.ts @@ -0,0 +1,4 @@ +import Layout from './layout'; + +export { LayoutProps } from './interfaces'; +export default Layout; diff --git a/src/components/layout/interfaces.ts b/src/components/layout/interfaces.ts new file mode 100644 index 0000000000..4a46329af5 --- /dev/null +++ b/src/components/layout/interfaces.ts @@ -0,0 +1,69 @@ +export interface LayoutProps { + className?: string; + prefixCls?: string; + children?: React.ReactNode | React.ReactNode[]; + style?: React.CSSProperties; +} + +export interface LayoutContentProps extends LayoutProps { + /** + Content 区域的最大宽度 + */ + maxWidth?: number | 'auto'; + /* + Content 区域的外边距 + */ + margin?: number; +} + +export interface LayoutSiderProps extends LayoutProps { + /* + 收缩状态的宽度 + */ + collapsedWidth?: number; + /* + 展开状态的宽度 + */ + width?: number; + /* + 默认的伸缩状态 + */ + defaultCollapsed?: boolean; + /* + 控制展开与收缩 + */ + collapsed?: boolean; + /* + 当伸缩状态变化时的回调函数 + */ + onCollapse?: (collapsed: boolean) => void; + /* + 悬浮式侧边栏的位置 + */ + suspendedPosition?: 'left' | 'right'; +} + +export interface SiderState { + id: string; + width: number; + collapsedWidth: number; + suspendedPosition?: 'left' | 'right'; +} + +export interface ContentState { + maxWidth: number; + margin: number; +} + +export interface LayoutState { + wide: boolean; +} + +export interface LayoutContextType { + layoutState: LayoutState; + contentState: ContentState; + setLayoutState: (layoutState: Partial | (() => Partial)) => void; + setContentState: (contentState: Partial | (() => Partial)) => void; + removeSider: (siderId: string) => void; + updateSiders: (sider: SiderState) => void; +} diff --git a/src/components/layout/layout.tsx b/src/components/layout/layout.tsx new file mode 100644 index 0000000000..84e2e7f08c --- /dev/null +++ b/src/components/layout/layout.tsx @@ -0,0 +1,61 @@ +import React, { useLayoutEffect, useRef, createContext } from 'react'; +import classNames from 'classnames'; +import { useWindowSize, useSetState } from 'react-use'; +import { LayoutProps, LayoutState, ContentState, LayoutContextType } from './interfaces'; +import usePrefixCls from '../../utils/hooks/use-prefix-cls'; +import Header from './header'; +import Content from './content'; +import Sider from './sider'; +import useSiders from './useSiders'; + +const initLayoutState = { wide: false }; +const initContentState = { maxWidth: 0, margin: 0 }; + +export const LayoutContext = createContext({ + layoutState: initLayoutState, + contentState: initContentState, +} as LayoutContextType); + +const Layout = ({ prefixCls: customizePrefixCls, className, style, children }: LayoutProps) => { + const { width } = useWindowSize(); + const containerRef = useRef(null); + const prefixCls = usePrefixCls('layout', customizePrefixCls); + const [localLayoutState, setLayoutState] = useSetState(initLayoutState); + const [localContentState, setContentState] = useSetState(initContentState); + const [siders, sidersWidth, removeSider, updateSiders, margin] = useSiders(); + + useLayoutEffect(() => { + const layoutWidth = containerRef.current?.getBoundingClientRect().width ?? 0; + setLayoutState(() => { + return { wide: layoutWidth > localContentState.maxWidth + 2 * localContentState.margin + sidersWidth }; + }); + }, [width, setLayoutState, localContentState, siders, sidersWidth]); + + const mergedStyle = { + ...{ marginLeft: margin[0], marginRight: margin[1] }, + ...style, + }; + + return ( + +
+ {children} +
+
+ ); +}; + +Layout.Header = Header; +Layout.Content = Content; +Layout.Sider = Sider; + +export default Layout; diff --git a/src/components/layout/sider.tsx b/src/components/layout/sider.tsx new file mode 100644 index 0000000000..9281e2b503 --- /dev/null +++ b/src/components/layout/sider.tsx @@ -0,0 +1,78 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React, { useContext, useEffect, useRef } from 'react'; +import classNames from 'classnames'; +import { LayoutSiderProps } from './interfaces'; +import usePrefixCls from '../../utils/hooks/use-prefix-cls'; +import { LayoutContext } from './layout'; +import useControlledState from '../../utils/hooks/useControlledState'; + +const generateId = (() => { + let i = 0; + return () => { + i += 1; + return `'sider'-${i}`; + }; +})(); + +const Sider = ({ + prefixCls: customizePrefixCls, + className, + style, + children, + collapsedWidth = 80, + width = 200, + collapsed, + defaultCollapsed = true, + onCollapse, + suspendedPosition, +}: LayoutSiderProps) => { + const { removeSider, updateSiders } = useContext(LayoutContext); + const [localCollapsed] = useControlledState(collapsed, defaultCollapsed); + const siderRef = useRef(null); + const siderId = useRef(); + + useEffect(() => { + const uniqueId = generateId(); + siderId.current = uniqueId; + return () => removeSider(uniqueId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const siderWidth = siderRef.current?.getBoundingClientRect().width ?? 0; + updateSiders({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + id: siderId.current!, + width: suspendedPosition || localCollapsed ? collapsedWidth : siderWidth, + collapsedWidth, + suspendedPosition, + }); + }, [collapsedWidth, localCollapsed, suspendedPosition, updateSiders]); + + useEffect(() => onCollapse?.(localCollapsed), [localCollapsed, onCollapse]); + + const prefixCls = usePrefixCls('layout-sider', customizePrefixCls); + const mergedStyle: React.CSSProperties = { + ['--layout-sider-width' as any]: `${width}px`, + ['--layout-sider-collapsedWidth' as any]: `${collapsedWidth}px`, + ['--layout-sider-collapsedWidth-negative' as any]: `-${collapsedWidth}px`, + ...style, + }; + + return ( + + ); +}; + +export default Sider; diff --git a/src/components/layout/style/index.less b/src/components/layout/style/index.less new file mode 100644 index 0000000000..c2bac7dc5d --- /dev/null +++ b/src/components/layout/style/index.less @@ -0,0 +1,46 @@ +@import '../../../stylesheet/index.less'; + +@layout-prefix-cls: ~'@{component-prefix}-layout'; + +.@{layout-prefix-cls} { + position: relative; + display: flex; + flex: auto; + flex-wrap: wrap; + + &-header { + flex: 0 0 100%; + height: 48px; + } + + &-content { + flex: var(--layout-content-grow) 1 var(--layout-content-maxWidth); + margin: 0 auto; + } + + &-sider { + flex: 0 0 var(--layout-sider-width); + transition: all 0.2s; + &-collapsed { + flex: 0 0 var(--layout-sider-collapsedWidth); + } + } + + &-sider-suspend { + position: absolute; + z-index: 1050; + width: var(--layout-sider-width); + height: 100%; + &-left { + left: 0; + transform: translateX(var(--layout-sider-collapsedWidth-negative)); + } + &-right { + right: 0; + transform: translateX(var(--layout-sider-collapsedWidth)); + } + &-collapsed { + width: var(--layout-sider-collapsedWidth); + } + } +} diff --git a/src/components/layout/style/index.ts b/src/components/layout/style/index.ts new file mode 100644 index 0000000000..17665f8be2 --- /dev/null +++ b/src/components/layout/style/index.ts @@ -0,0 +1 @@ +import './index.less'; \ No newline at end of file diff --git a/src/components/layout/useSiders.ts b/src/components/layout/useSiders.ts new file mode 100644 index 0000000000..6a20ed431b --- /dev/null +++ b/src/components/layout/useSiders.ts @@ -0,0 +1,39 @@ +import { useState, useMemo, useCallback } from 'react'; +import { SiderState } from './interfaces'; + +const useSiders = (): [ + SiderState[], + number, + (siderId: string) => void, + (sider: SiderState) => void, + [number, number] +] => { + const [siders, setSiders] = useState([]); + + const removeSider = (siderId: string) => { + setSiders((_siders) => _siders.filter((_sider) => _sider.id !== siderId)); + }; + + const updateSiders = useCallback((incomingSider: SiderState) => { + setSiders((_siders) => { + _siders.filter((_sider) => _sider.id !== incomingSider.id); + return [..._siders, incomingSider]; + }); + }, []); + + const sidersWidth = useMemo(() => siders.reduce((prev: number, current: SiderState) => prev + current.width, 0), [ + siders, + ]); + + const margin: [number, number] = useMemo( + () => [ + siders.find((sider) => sider.suspendedPosition === 'left')?.collapsedWidth ?? 0, + siders.find((sider) => sider.suspendedPosition === 'right')?.collapsedWidth ?? 0, + ], + [siders] + ); + + return [siders, sidersWidth, removeSider, updateSiders, margin]; +}; + +export default useSiders;