Skip to content

Commit

Permalink
탭 컴포넌트 (#35)
Browse files Browse the repository at this point in the history
* 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 피드백 반영
  • Loading branch information
springkjw authored Jan 31, 2021
1 parent 976ce6f commit 8028fbb
Show file tree
Hide file tree
Showing 19 changed files with 612 additions and 0 deletions.
2 changes: 2 additions & 0 deletions ui-kit/src/components/Container/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.container {
}
8 changes: 8 additions & 0 deletions ui-kit/src/components/Tabs/TabContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createContext } from 'react';
import { Tab } from './types';

export interface TabContextProps {
tabs: Tab[];
}

export default createContext<TabContextProps>({ tabs: [] });
5 changes: 5 additions & 0 deletions ui-kit/src/components/Tabs/TabNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import React from 'react';

export default function TabNav() {
return <div></div>;
}
142 changes: 142 additions & 0 deletions ui-kit/src/components/Tabs/TabNavList.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>) {
const { tabs } = useContext(TabContext);
const { id, activeKey, animated, tabWidth, onTabClick } = props;

const tabsWrapperRef = useRef<HTMLDivElement>();
const tabListRef = useRef<HTMLDivElement>();
const getTabRef = useRefs<HTMLDivElement>();

const [wrapperScrollWidth, setWrapperScrollWidth] = useState<number>(0);
const [wrapperContentWidth, setWrapperContentWidth] = useState<number>(0);
const [wrapperWidth, setWrapperWidth] = useState<number>(0);
const [barStyle, setBarStyle] = useState<React.CSSProperties>();
const [tabSizes, setTabSizes] = useState<TabSizeMap>(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 (
<TabNode
id={id}
tab={tab}
key={key}
ref={getTabRef(key)}
tabWidth={tabWidth}
active={key === activeKey}
onClick={(e) => {
onTabClick(key, e);
}}
onFocus={() => {
scrollToTab(key);

tabsWrapperRef.current.scrollToLeft = 0;
}}
/>
);
});

return (
<div ref={ref} role="tablist" className={classnames('lubycon-tab__nav')}>
<div ref={tabsWrapperRef} className={classnames('lubycon-tab__nav__wrap')}>
<div ref={tabListRef} className="lubycon-nav-list">
{tabNodes}
</div>

<div
className={classnames(`lubycon-tab__bar`, {
['lubycon-tab__bar__animated']: animated ? animated : true,
})}
style={barStyle}
/>
</div>
</div>
);
}

export default React.forwardRef(TabNavList);
61 changes: 61 additions & 0 deletions ui-kit/src/components/Tabs/TabNode.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>
) {
const tabPrefix = 'lubycon-tab';

const nodeStyle: React.CSSProperties = { width: tabWidth };

function onInternalClick(e: React.MouseEvent) {
if (disabled) {
return;
}

onClick?.(e);
}

return (
<div
key={key}
ref={ref}
className={classNames(tabPrefix, {
[`${tabPrefix}__active`]: active,
[`${tabPrefix}__disabled`]: disabled,
})}
style={nodeStyle}
onClick={onInternalClick}
>
<div
role="tab"
aria-selected={active}
id={id !== null ? `${id}-tab-${key}` : ''}
className={`${tabPrefix}__btn`}
aria-controls={id !== null ? `${id}-panel-${key}` : ''}
aria-disabled={disabled}
tabIndex={disabled ? undefined : 0}
onClick={(e) => {
e.stopPropagation();
onInternalClick(e);
}}
onFocus={onFocus}
>
{tab}
</div>
</div>
);
}

export default React.forwardRef(TabNode);
35 changes: 35 additions & 0 deletions ui-kit/src/components/Tabs/TabPane.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="tabpanel"
tabIndex={active ? 0 : -1}
aria-hidden={!active}
className={classnames('lubycon-tab__pane', active && `lubycon-tab__pane__active`)}
style={{ ...mergedStyle }}
>
{active && children}
</div>
);
}
37 changes: 37 additions & 0 deletions ui-kit/src/components/Tabs/TabPanelList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={classnames('lubycon-tab__content__holder')}>
<div
className={classnames('lubycon-tab__content', {
['lubycon-tab__content__animated']: tabPaneAnimated,
})}
style={
activeIndex && tabPaneAnimated ? { ['marginLeft']: `-${activeIndex}00%` } : undefined
}
>
{tabs.map((tab) => {
return React.cloneElement(tab.node, {
key: tab.key,
tabKey: tab.key,
animated: tabPaneAnimated,
active: tab.key === activeKey,
});
})}
</div>
</div>
);
}
84 changes: 84 additions & 0 deletions ui-kit/src/components/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -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<React.HTMLAttributes<HTMLDivElement>, '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<TabPaneProps>) => {
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<HTMLDivElement>
) {
const tabs = parseTabList(children);

const [mergedActiveKey, setMergedActiveKey] = useMergedState<string>(() => 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 = <TabNavList {...tabNavBarProps} />;

return (
<TabContext.Provider value={{ tabs }}>
<div ref={ref} className={classnames('lubycon-tabs')}>
{tabNavBar}

<TabPanelList activeKey={mergedActiveKey} animated={animated} />
</div>
</TabContext.Provider>
);
}

const ForwardTabs = React.forwardRef(Tabs);

export type ForwardTabsType = typeof ForwardTabs & { TabPane: typeof TabPane };

(ForwardTabs as ForwardTabsType).TabPane = TabPane;

export default ForwardTabs as ForwardTabsType;
2 changes: 2 additions & 0 deletions ui-kit/src/components/Tabs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as Tabs } from './Tabs';
export { default as TabPane } from './TabPane';
Loading

0 comments on commit 8028fbb

Please sign in to comment.