From 8028fbbcd27b9622f011011b152e381842b4a066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=EC=9E=AC=EC=9B=90?= Date: Sun, 31 Jan 2021 19:57:13 +0900 Subject: [PATCH] =?UTF-8?q?=ED=83=AD=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui-kit): Container 컴포넌트 추가 * feat(ui-kit): Tab 컴포넌트 골격 * feat(ui-kit): Tab 스타일 * feat(ui-kit): Tab active 기능 * feat(ui-kit): Tab Bar 애니메이션 추가 * feat(ui-kit): Fixed Width Tab 구현 * feat(ui-kit): Tab Content 구현 * fix(ui-kit): QA 피드백 반영 --- ui-kit/src/components/Container/style.scss | 2 + ui-kit/src/components/Tabs/TabContext.ts | 8 ++ ui-kit/src/components/Tabs/TabNav.tsx | 5 + ui-kit/src/components/Tabs/TabNavList.tsx | 142 ++++++++++++++++++++ ui-kit/src/components/Tabs/TabNode.tsx | 61 +++++++++ ui-kit/src/components/Tabs/TabPane.tsx | 35 +++++ ui-kit/src/components/Tabs/TabPanelList.tsx | 37 +++++ ui-kit/src/components/Tabs/Tabs.tsx | 84 ++++++++++++ ui-kit/src/components/Tabs/index.ts | 2 + ui-kit/src/components/Tabs/types.ts | 20 +++ ui-kit/src/components/index.ts | 1 + ui-kit/src/hooks/index.ts | 2 + ui-kit/src/hooks/useMergedState.ts | 50 +++++++ ui-kit/src/hooks/useRefs.ts | 15 +++ ui-kit/src/sass/components/_Tabs.scss | 58 ++++++++ ui-kit/src/sass/components/_index.scss | 1 + ui-kit/src/stories/Tabs.stories.tsx | 62 +++++++++ ui-kit/src/utils/index.ts | 1 + ui-kit/src/utils/toArray.ts | 26 ++++ 19 files changed, 612 insertions(+) create mode 100644 ui-kit/src/components/Container/style.scss create mode 100644 ui-kit/src/components/Tabs/TabContext.ts create mode 100644 ui-kit/src/components/Tabs/TabNav.tsx create mode 100644 ui-kit/src/components/Tabs/TabNavList.tsx create mode 100644 ui-kit/src/components/Tabs/TabNode.tsx create mode 100644 ui-kit/src/components/Tabs/TabPane.tsx create mode 100644 ui-kit/src/components/Tabs/TabPanelList.tsx create mode 100644 ui-kit/src/components/Tabs/Tabs.tsx create mode 100644 ui-kit/src/components/Tabs/index.ts create mode 100644 ui-kit/src/components/Tabs/types.ts create mode 100644 ui-kit/src/hooks/index.ts create mode 100644 ui-kit/src/hooks/useMergedState.ts create mode 100644 ui-kit/src/hooks/useRefs.ts create mode 100644 ui-kit/src/sass/components/_Tabs.scss create mode 100644 ui-kit/src/stories/Tabs.stories.tsx create mode 100644 ui-kit/src/utils/toArray.ts diff --git a/ui-kit/src/components/Container/style.scss b/ui-kit/src/components/Container/style.scss new file mode 100644 index 00000000..48514080 --- /dev/null +++ b/ui-kit/src/components/Container/style.scss @@ -0,0 +1,2 @@ +.container { +} diff --git a/ui-kit/src/components/Tabs/TabContext.ts b/ui-kit/src/components/Tabs/TabContext.ts new file mode 100644 index 00000000..fa39f2b4 --- /dev/null +++ b/ui-kit/src/components/Tabs/TabContext.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react'; +import { Tab } from './types'; + +export interface TabContextProps { + tabs: Tab[]; +} + +export default createContext({ tabs: [] }); diff --git a/ui-kit/src/components/Tabs/TabNav.tsx b/ui-kit/src/components/Tabs/TabNav.tsx new file mode 100644 index 00000000..add985d0 --- /dev/null +++ b/ui-kit/src/components/Tabs/TabNav.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function TabNav() { + return
; +} diff --git a/ui-kit/src/components/Tabs/TabNavList.tsx b/ui-kit/src/components/Tabs/TabNavList.tsx new file mode 100644 index 00000000..645ac16d --- /dev/null +++ b/ui-kit/src/components/Tabs/TabNavList.tsx @@ -0,0 +1,142 @@ +import React, { useContext, useState, useEffect, useMemo, useRef } from 'react'; +import classnames from 'classnames'; +import TabNode from './TabNode'; +import TabContext from './TabContext'; +import { TabOffsetMap, TabOffset, TabSizeMap } from './types'; +import { useRefs } from '../../hooks'; + +const DEFAULT_SIZE = { width: 0, height: 0, left: 0, top: 0 }; + +export interface TabNavListProps { + id: string; + activeKey: string; + panes: React.ReactNode; + animated?: boolean; + tabBarGutter?: number; + tabWidth?: number; + onTabClick: (activeKey: string, e: React.MouseEvent) => void; + children?: (node: React.ReactElement) => React.ReactElement; +} + +function TabNavList(props: TabNavListProps, ref: React.Ref) { + const { tabs } = useContext(TabContext); + const { id, activeKey, animated, tabWidth, onTabClick } = props; + + const tabsWrapperRef = useRef(); + const tabListRef = useRef(); + const getTabRef = useRefs(); + + const [wrapperScrollWidth, setWrapperScrollWidth] = useState(0); + const [wrapperContentWidth, setWrapperContentWidth] = useState(0); + const [wrapperWidth, setWrapperWidth] = useState(0); + const [barStyle, setBarStyle] = useState(); + const [tabSizes, setTabSizes] = useState(new Map()); + + const tabOffsets = useMemo(() => { + const map: TabOffsetMap = new Map(); + + const lastOffset = tabSizes.get(tabs[0].key) ?? DEFAULT_SIZE; + const rightOffset = lastOffset.left + lastOffset.width; + + for (let i = 0; i < tabs.length; i += 1) { + const { key } = tabs[i]; + let data = tabSizes.get(key); + + if (!data) { + data = tabSizes.get(tabs[i - 1]?.key) || DEFAULT_SIZE; + } + + const entity = (map.get(key) || { ...data }) as TabOffset; + entity.right = rightOffset - entity.left - entity.width; + map.set(key, entity); + } + + return map; + }, [tabs.map((tab) => tab.key).join('_'), tabSizes, wrapperScrollWidth]); + + const activeTabOffset = tabOffsets.get(activeKey); + + useEffect(() => { + const newBarStyle: React.CSSProperties = {}; + + if (activeTabOffset) { + newBarStyle.left = activeTabOffset.left; + newBarStyle.width = tabWidth ? tabWidth : activeTabOffset.width; + } + + setBarStyle(newBarStyle); + }, [activeTabOffset, tabWidth]); + + useEffect(() => { + const offsetWidth = tabsWrapperRef.current?.offsetWidth || 0; + + setWrapperWidth(offsetWidth); + + const newWrapperScrollWidth = tabListRef.current?.offsetWidth || 0; + + setWrapperScrollWidth(newWrapperScrollWidth); + + setWrapperContentWidth(newWrapperScrollWidth); + + setTabSizes(() => { + const newSizes: TabSizeMap = new Map(); + tabs.forEach(({ key }) => { + const tabNode = getTabRef(key).current; + + if (tabNode) { + newSizes.set(key, { + width: tabNode.offsetWidth, + height: tabNode.offsetHeight, + left: tabNode.offsetLeft, + top: tabNode.offsetTop, + }); + } + }); + + return newSizes; + }); + }, []); + + function scrollToTab(key = activeKey) {} + + const tabNodes: React.ReactElement[] = tabs.map((tab) => { + const { key } = tab; + return ( + { + onTabClick(key, e); + }} + onFocus={() => { + scrollToTab(key); + + tabsWrapperRef.current.scrollToLeft = 0; + }} + /> + ); + }); + + return ( +
+
+
+ {tabNodes} +
+ +
+
+
+ ); +} + +export default React.forwardRef(TabNavList); diff --git a/ui-kit/src/components/Tabs/TabNode.tsx b/ui-kit/src/components/Tabs/TabNode.tsx new file mode 100644 index 00000000..1aadc50f --- /dev/null +++ b/ui-kit/src/components/Tabs/TabNode.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import { Tab } from './types'; + +export interface TabNodeProps { + id: string; + tab: Tab; + active: boolean; + tabWidth?: number; + onClick?: (e: React.MouseEvent) => void; + onFocus: React.FocusEventHandler; +} + +function TabNode( + { id, active, tab: { key, tab, disabled }, tabWidth, onClick, onFocus }: TabNodeProps, + ref: React.Ref +) { + const tabPrefix = 'lubycon-tab'; + + const nodeStyle: React.CSSProperties = { width: tabWidth }; + + function onInternalClick(e: React.MouseEvent) { + if (disabled) { + return; + } + + onClick?.(e); + } + + return ( +
+ +
+ ); +} + +export default React.forwardRef(TabNode); diff --git a/ui-kit/src/components/Tabs/TabPane.tsx b/ui-kit/src/components/Tabs/TabPane.tsx new file mode 100644 index 00000000..476191fa --- /dev/null +++ b/ui-kit/src/components/Tabs/TabPane.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import classnames from 'classnames'; + +export interface TabPaneProps { + tab?: React.ReactNode; + children?: React.ReactNode; + active?: boolean; + animated?: boolean; + disabled?: boolean; +} + +export default function TabPane({ active, animated, children }: TabPaneProps) { + const mergedStyle: React.CSSProperties = {}; + if (!active) { + if (animated) { + mergedStyle.visibility = 'hidden'; + mergedStyle.height = 0; + mergedStyle.overflowY = 'hidden'; + } else { + mergedStyle.display = 'none'; + } + } + + return ( +
+ {active && children} +
+ ); +} diff --git a/ui-kit/src/components/Tabs/TabPanelList.tsx b/ui-kit/src/components/Tabs/TabPanelList.tsx new file mode 100644 index 00000000..04d4950f --- /dev/null +++ b/ui-kit/src/components/Tabs/TabPanelList.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import classnames from 'classnames'; +import TabContext from './TabContext'; + +export interface TabPanelListProps { + activeKey: React.Key; + animated?: boolean; +} + +export default function TabPanelList({ activeKey, animated }: TabPanelListProps) { + const { tabs } = React.useContext(TabContext); + const tabPaneAnimated = animated; + + const activeIndex = tabs.findIndex((tab) => tab.key === activeKey); + + return ( +
+
+ {tabs.map((tab) => { + return React.cloneElement(tab.node, { + key: tab.key, + tabKey: tab.key, + animated: tabPaneAnimated, + active: tab.key === activeKey, + }); + })} +
+
+ ); +} diff --git a/ui-kit/src/components/Tabs/Tabs.tsx b/ui-kit/src/components/Tabs/Tabs.tsx new file mode 100644 index 00000000..e14289cf --- /dev/null +++ b/ui-kit/src/components/Tabs/Tabs.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import classnames from 'classnames'; +import TabPane, { TabPaneProps } from './TabPane'; +import TabNavList from './TabNavList'; +import { Tab } from './types'; +import { useMergedState } from '../../hooks'; +import { toArray } from '../../utils'; +import TabContext from './TabContext'; +import TabPanelList from './TabPanelList'; + +export interface TabsProps extends Omit, 'onChange'> { + children?: React.ReactNode; + activeKey?: string; + defaultActiveKey?: string; + animated?: boolean; + tabWidth?: number; + onTabClick?: (activeKey: string, e: React.MouseEvent) => void; + onChange?: (activeKey: string) => void; +} + +function parseTabList(children: React.ReactNode): Tab[] { + return toArray(children) + .map((node: React.ReactElement) => { + if (React.isValidElement(node)) { + const key = node.key !== undefined ? String(node.key) : undefined; + return { + key, + ...node.props, + node, + }; + } + + return null; + }) + .filter((tab) => tab); +} + +function Tabs( + { children, activeKey, defaultActiveKey, animated, tabWidth, onTabClick, onChange }: TabsProps, + ref: React.Ref +) { + const tabs = parseTabList(children); + + const [mergedActiveKey, setMergedActiveKey] = useMergedState(() => tabs[0]?.key, { + value: activeKey, + defaultValue: defaultActiveKey, + }); + + function onInternalTabClick(key: string, e: React.MouseEvent) { + onTabClick?.(key, e); + + setMergedActiveKey(key); + onChange?.(key); + } + + const tabNavBarProps = { + id: '', + activeKey: mergedActiveKey, + animated, + tabWidth, + panes: children, + onTabClick: onInternalTabClick, + }; + + const tabNavBar: React.ReactElement = ; + + return ( + +
+ {tabNavBar} + + +
+
+ ); +} + +const ForwardTabs = React.forwardRef(Tabs); + +export type ForwardTabsType = typeof ForwardTabs & { TabPane: typeof TabPane }; + +(ForwardTabs as ForwardTabsType).TabPane = TabPane; + +export default ForwardTabs as ForwardTabsType; diff --git a/ui-kit/src/components/Tabs/index.ts b/ui-kit/src/components/Tabs/index.ts new file mode 100644 index 00000000..77aae0ff --- /dev/null +++ b/ui-kit/src/components/Tabs/index.ts @@ -0,0 +1,2 @@ +export { default as Tabs } from './Tabs'; +export { default as TabPane } from './TabPane'; diff --git a/ui-kit/src/components/Tabs/types.ts b/ui-kit/src/components/Tabs/types.ts new file mode 100644 index 00000000..5fc861ab --- /dev/null +++ b/ui-kit/src/components/Tabs/types.ts @@ -0,0 +1,20 @@ +import { TabPaneProps } from './TabPane'; + +export type TabSizeMap = Map< + React.Key, + { width: number; height: number; left: number; top: number } +>; + +export interface TabOffset { + width: number; + height: number; + left: number; + right: number; + top: number; +} +export type TabOffsetMap = Map; + +export interface Tab extends TabPaneProps { + key: string; + node: React.ReactElement; +} diff --git a/ui-kit/src/components/index.ts b/ui-kit/src/components/index.ts index 3d96c6df..d5abb75b 100644 --- a/ui-kit/src/components/index.ts +++ b/ui-kit/src/components/index.ts @@ -7,3 +7,4 @@ export { default as Switch } from './Switch'; export { default as Text } from './Text'; export { default as LubyconUIKitProvider } from './LubyconUIKitProvider'; export { default as Toast } from './Toast'; +export { Tabs, TabPane } from './Tabs'; diff --git a/ui-kit/src/hooks/index.ts b/ui-kit/src/hooks/index.ts new file mode 100644 index 00000000..55439924 --- /dev/null +++ b/ui-kit/src/hooks/index.ts @@ -0,0 +1,2 @@ +export { default as useMergedState } from './useMergedState'; +export { default as useRefs } from './useRefs'; diff --git a/ui-kit/src/hooks/useMergedState.ts b/ui-kit/src/hooks/useMergedState.ts new file mode 100644 index 00000000..ec9755dd --- /dev/null +++ b/ui-kit/src/hooks/useMergedState.ts @@ -0,0 +1,50 @@ +import React from 'react'; + +export default function useMergedState( + defaultStateValue: T | (() => T), + option?: { + defaultValue?: T | (() => T); + value?: T; + onChange?: (value: T, prevValue: T) => void; + postState?: (value: T) => T; + } +): [R, (value: T) => void] { + const { defaultValue, value, onChange, postState } = option || {}; + const [innerValue, setInnerValue] = React.useState(() => { + if (value !== undefined) { + return value; + } + if (defaultValue !== undefined) { + return typeof defaultValue === 'function' ? (defaultValue as any)() : defaultValue; + } + return typeof defaultStateValue === 'function' + ? (defaultStateValue as any)() + : defaultStateValue; + }); + + let mergedValue = value !== undefined ? value : innerValue; + if (postState) { + mergedValue = postState(mergedValue); + } + + function triggerChange(newValue: T) { + setInnerValue(newValue); + if (mergedValue !== newValue && onChange) { + onChange(newValue, mergedValue); + } + } + + const firstRenderRef = React.useRef(true); + React.useEffect(() => { + if (firstRenderRef.current) { + firstRenderRef.current = false; + return; + } + + if (value === undefined) { + setInnerValue(value); + } + }, [value]); + + return [(mergedValue as unknown) as R, triggerChange]; +} diff --git a/ui-kit/src/hooks/useRefs.ts b/ui-kit/src/hooks/useRefs.ts new file mode 100644 index 00000000..2bf63d2e --- /dev/null +++ b/ui-kit/src/hooks/useRefs.ts @@ -0,0 +1,15 @@ +import React, { useRef } from 'react'; + +// Key 기반으로 RefObject 저장하고 캐시로 성능 향상화 +export default function useRefs(): (key: React.Key) => React.RefObject { + const cacheRefs = useRef(new Map>()); + + function getRef(key: React.Key) { + if (!cacheRefs.current.has(key)) { + cacheRefs.current.set(key, React.createRef()); + } + return cacheRefs.current.get(key); + } + + return getRef; +} diff --git a/ui-kit/src/sass/components/_Tabs.scss b/ui-kit/src/sass/components/_Tabs.scss new file mode 100644 index 00000000..b21dc51c --- /dev/null +++ b/ui-kit/src/sass/components/_Tabs.scss @@ -0,0 +1,58 @@ +.lubycon-tabs { + .lubycon-nav-list { + display: flex; + flex-direction: row; + position: relative; + + .lubycon-tab { + display: flex; + justify-content: center; + align-items: center; + padding: 12px 40px; + line-height: 24px; + font-size: 14px; + cursor: pointer; + + border-bottom: 1px solid get-color('gray30'); + box-sizing: border-box; + + height: 44px; + @include font-weight('regular', 400); + + &.lubycon-tab__active { + color: get-color('gray100'); + @include font-weight('bold', 700); + } + + &:hover { + color: get-color('gray80'); + border-bottom: 1px solid get-color('gray80'); + } + + .lubycon-tab__btn { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 44px; + white-space: nowrap; + } + } + } + + .lubycon-tab__nav__wrap { + position: relative; + + .lubycon-tab__bar { + height: 2px; + background-color: get-color('blue50'); + position: absolute; + bottom: 0; + + &.lubycon-tab__bar__animated { + pointer-events: none; + transition: all 0.3s; + } + } + } +} diff --git a/ui-kit/src/sass/components/_index.scss b/ui-kit/src/sass/components/_index.scss index 11b4db9f..62cc9b95 100644 --- a/ui-kit/src/sass/components/_index.scss +++ b/ui-kit/src/sass/components/_index.scss @@ -8,3 +8,4 @@ @import './Selection'; @import './Icon'; @import './Toast'; +@import './Tabs'; diff --git a/ui-kit/src/stories/Tabs.stories.tsx b/ui-kit/src/stories/Tabs.stories.tsx new file mode 100644 index 00000000..c66691fe --- /dev/null +++ b/ui-kit/src/stories/Tabs.stories.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { Meta } from '@storybook/react/types-6-0'; +import { Tabs, TabPane } from 'components/Tabs'; + +export default { + title: 'Lubycon UI Kit/Tab', + component: Tabs, +} as Meta; + +export const Default = () => { + return ( +
+ + + first + + + second + + + third + + + fourth + + + fifth + + + sixth + + +
+ ); +}; + +export const FixedTab = () => { + return ( +
+ + + first + + + second + + + third + + + fourth + + + fifth + + + sixth + + +
+ ); +}; diff --git a/ui-kit/src/utils/index.ts b/ui-kit/src/utils/index.ts index 14be0d6d..d19a89a2 100644 --- a/ui-kit/src/utils/index.ts +++ b/ui-kit/src/utils/index.ts @@ -1 +1,2 @@ export { generateID } from './generateID'; +export { default as toArray } from './toArray'; diff --git a/ui-kit/src/utils/toArray.ts b/ui-kit/src/utils/toArray.ts new file mode 100644 index 00000000..4c3bc451 --- /dev/null +++ b/ui-kit/src/utils/toArray.ts @@ -0,0 +1,26 @@ +import React from 'react'; + +export interface Option { + keepEmpty?: boolean; +} + +export default function toArray( + children: React.ReactNode, + option: Option = {} +): React.ReactElement[] { + let ret: React.ReactElement[] = []; + + React.Children.forEach(children, (child: any) => { + if ((child === undefined || child === null) && !option.keepEmpty) { + return; + } + + if (Array.isArray(child)) { + ret = ret.concat(toArray(child)); + } else { + ret.push(child); + } + }); + + return ret; +}