+ Tabbar Component

This commit is contained in:
Pogodaanton 2020-06-24 14:04:46 +02:00
parent 58c0038f84
commit 26b9de9051
6 changed files with 406 additions and 0 deletions

View File

@ -0,0 +1,28 @@
import {
TabProps as BaseTabProps,
TabsManagedClasses as BaseTabsManagedClasses,
} from "@microsoft/fast-components-react-base";
import { Subtract } from "utility-types";
import { IconType } from "react-icons/lib";
import { TabClassNameContract as BaseTabClassNameContract } from "@microsoft/fast-components-class-name-contracts-base";
import { ManagedClasses } from "@microsoft/fast-jss-manager-react";
/**
* Class name contract for the component
*/
export interface TabClassNameContract extends BaseTabClassNameContract {
tab_title: string;
tab_icon?: string;
}
/**
* Props for the component
*/
export interface TabProps
extends ManagedClasses<TabClassNameContract>,
Subtract<BaseTabProps, BaseTabsManagedClasses> {
/**
* Custom icon prepended to the tab title
*/
icon?: IconType;
}

View File

@ -0,0 +1,45 @@
import React from "react";
import {
DesignSystem,
neutralForegroundRest,
} from "@microsoft/fast-components-styles-msft";
import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react";
import { Tab as BaseTab } from "@microsoft/fast-components-react-base";
import { TabProps, TabClassNameContract } from "./Tab.props";
const styles: ComponentStyles<TabClassNameContract, DesignSystem> = {
tab: {
color: neutralForegroundRest,
margin: "0px 10px",
padding: "5px 0 3px",
cursor: "pointer",
display: "flex",
alignItems: "center",
},
tab__active: {
cursor: "default",
},
tab_title: {},
tab_icon: {
marginRight: "5px",
},
};
/**
* Custom tab element containing an optional icon prop
*/
const Tab: React.ComponentType<TabProps> = ({
icon: Icon,
children,
managedClasses: { tab_title, tab_icon, ...managedClasses },
...props
}) => (
<BaseTab {...props} managedClasses={managedClasses}>
{Icon && <Icon className={tab_icon} tabIndex={-1} />}
<span className={tab_title} tabIndex={-1}>
{children}
</span>
</BaseTab>
);
export default manageJss(styles)(Tab);

View File

@ -0,0 +1,54 @@
import { IconType } from "react-icons/lib";
import { ManagedClasses } from "@microsoft/fast-jss-manager-react";
import { Orientation } from "@microsoft/fast-web-utilities";
/**
* Class name contract for the component
*/
export interface TabBarClassNameContract {
tabBar: string;
tab?: string;
/**
* Indicator for the currently selected item
*/
tabBar_indicator: string;
}
/**
* Object containing all the necessary data for a tab to be displayed
*/
export interface TabItem {
/**
* Common identifier of the tab.
* This is also used to determine the corresponding tab panel.
*/
id: string;
title: React.ReactText | React.ReactNode;
icon: IconType; //| React.ReactNode;
}
/**
* Props for the component
*/
export interface TabBarProps extends ManagedClasses<TabBarClassNameContract> {
/**
* Unique identifier used to establish connection between tabbar and tab-listeners
*/
id: string;
/**
* The list of tabs on the tabbar
*/
items: TabItem[];
/**
* The aria-label applied to the tabbar
*/
label: string;
/**
* The orientation for the tabbar
*/
orientation?: Orientation;
}

View File

@ -0,0 +1,262 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import { TabBarProps, TabBarClassNameContract, TabItem } from "./TabBar.props";
import {
DesignSystem,
accentFillActive,
accentFillHover,
neutralFillHover,
neutralFillActive,
} from "@microsoft/fast-components-styles-msft";
import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react";
import Tab from "../Tab/Tab";
import { TabsSlot, TabLocation } from "@microsoft/fast-components-react-base";
import {
keyCodeArrowLeft,
Orientation,
keyCodeArrowRight,
keyCodeArrowUp,
keyCodeArrowDown,
keyCodeHome,
keyCodeEnd,
} from "@microsoft/fast-web-utilities";
import tabEventEmitter from "../TabEvents";
import { TabClassNameContract } from "../Tab/Tab.props";
import { parseColorHexRGBA } from "@microsoft/fast-colors";
const styles: ComponentStyles<TabBarClassNameContract, DesignSystem> = {
tabBar: {
display: "inline-flex",
position: "relative",
paddingBottom: "3px",
"&:not(.focus-visible), & *": {
outline: "none",
},
},
tabBar_indicator: {
background: accentFillActive,
position: "absolute",
bottom: "0",
left: "0",
width: "1px",
height: "3px",
transition: "all .2s",
transformOrigin: "left",
},
};
const tabStyles: ComponentStyles<TabClassNameContract, DesignSystem> = {
tab_title: {},
tab: {
position: "relative",
"&::after": {
content: "''",
position: "absolute",
width: "100%",
height: "3px",
opacity: "0",
background: neutralFillHover,
bottom: "-3px",
transition: "opacity .1s",
},
"&:hover::after": {
opacity: "1",
},
},
};
/**
* Custom tabbar which can be connected to a seperate tabpanel.
* Some parts of the code were borrowed from FAST's <Tabs/>
*/
const TabBar: React.ComponentType<TabBarProps> = React.memo(
({ managedClasses, id, items, orientation = Orientation.horizontal, ...props }) => {
/**
* Id of the currently selected tab
*/
const [activeId, setActiveId] = useState<string>(null);
/**
* Stylesheet for the active tab indicator
*/
const [activeIndicatorStyles, setAactiveIndicatorStyles] = useState<
React.CSSProperties
>({});
/**
* Ref of the main tabbar
*/
const tabBarRef = useRef<HTMLDivElement>();
/**
* Looks up whether the given tab is currently selected or not
*/
const getActiveState = useCallback(
(tabItem: TabItem, index: number): boolean => {
if (activeId) return activeId === tabItem.id;
return index === 0;
},
[activeId]
);
/**
* Activates a tab and focuses on the new element
*/
const activateTab = (location: TabLocation): void => {
const count = items.length;
const currentItemIndex: number = items.findIndex(getActiveState);
let itemIndex: number;
switch (location) {
case TabLocation.first:
itemIndex = 0;
break;
case TabLocation.last:
itemIndex = count - 1;
break;
case TabLocation.previous:
itemIndex = currentItemIndex > 0 ? currentItemIndex - 1 : count - 1;
break;
case TabLocation.next:
itemIndex = currentItemIndex < count - 1 ? currentItemIndex + 1 : 0;
break;
}
const nextActiveId = items[itemIndex].id;
setActiveId(nextActiveId);
(Array.from(tabBarRef.current.children)[itemIndex] as HTMLButtonElement).focus();
};
/**
* Handles the click event of a tab element
*/
const handleClick = (e: React.MouseEvent<HTMLDivElement>): void => {
setActiveId(e.currentTarget.getAttribute("aria-controls"));
};
/**
* Handles the keydown event on a tab element
*/
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>): void => {
const keyCode: number = e.keyCode;
if (orientation === Orientation.horizontal) {
switch (keyCode) {
case keyCodeArrowLeft:
e.preventDefault();
activateTab(TabLocation.previous);
break;
case keyCodeArrowRight:
e.preventDefault();
activateTab(TabLocation.next);
break;
}
} else {
switch (e.keyCode) {
case keyCodeArrowUp:
e.preventDefault();
activateTab(TabLocation.previous);
break;
case keyCodeArrowDown:
e.preventDefault();
activateTab(TabLocation.next);
break;
}
}
switch (keyCode) {
case keyCodeHome:
activateTab(TabLocation.first);
break;
case keyCodeEnd:
activateTab(TabLocation.last);
break;
}
};
/**
* Emitting events on changes in the currently selected tab,
* so that other components can react to the changes. (e.g.: TabPanel)
*/
useEffect(() => {
if (!activeId) return;
const activeItem = items.find(getActiveState);
tabEventEmitter.emit(`${id}-change`, activeItem);
/**
* Sends necessary tabbar data to new subscribers.
*/
const subHandler = () => tabEventEmitter.emit(`${id}-subPub`, activeItem);
tabEventEmitter.on(`${id}-sub`, subHandler);
return () => {
tabEventEmitter.off(`${id}-sub`, subHandler);
};
}, [activeId, getActiveState, id, items]);
const renderTabs = (): JSX.Element[] => {
if (items) {
return items.map((tabItem: TabItem, index: number) => {
const isActive = getActiveState(tabItem, index);
return (
<Tab
slot={TabsSlot.tab}
key={tabItem.id}
aria-controls={tabItem.id}
active={isActive}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={isActive ? 0 : -1}
icon={tabItem.icon}
jssStyleSheet={tabStyles}
>
{tabItem.title}
</Tab>
);
});
}
return null;
};
useEffect(() => {
if (tabBarRef.current && Array.isArray(items)) {
const tabBar: HTMLDivElement = tabBarRef.current;
const selectedTab: HTMLElement = tabBar.querySelector("[aria-selected='true']");
// Default values
let width: number = 0;
let posX: number = 0;
if (selectedTab !== null) {
width = selectedTab.getBoundingClientRect().width;
posX =
(selectedTab.getBoundingClientRect().left -
tabBar.getBoundingClientRect().left) /
width;
}
setAactiveIndicatorStyles({
transform: `scaleX(${width}) translateX(${posX}px)`,
});
}
}, [items, activeId]);
return (
<div
role="tablist"
ref={tabBarRef}
className={managedClasses.tabBar}
aria-label={props.label}
aria-orientation={orientation}
>
{renderTabs()}
<div
className={managedClasses.tabBar_indicator}
tabIndex={-1}
style={activeIndicatorStyles}
/>
</div>
);
}
);
export default manageJss(styles)(TabBar);

View File

@ -0,0 +1,15 @@
import EventEmitter from "onfire.js";
/**
* Cross-component event event manager for tabs
*/
const tabEventEmitter: EventEmitter = new EventEmitter();
/*
const origOnFunction = tabEventEmitter.on;
tabEventEmitter.on = (eventName: string, cb: Function, once?: boolean) => {
origOnFunction(eventName, cb, once);
}
*/
export default tabEventEmitter;

View File

@ -16,6 +16,7 @@ import ProgressIcon from "./ProgressIcon/ProgressIcon";
import useMotionValueFactory from "./Hooks/useMotionValueFactory";
import { useScaleFactor } from "./Hooks/useScaleFactor";
import { ToastManager, toast } from "./Toasts/toast";
import TabBar from "./Tabs/TabBar/TabBar";
export {
Button,
@ -37,4 +38,5 @@ export {
useScaleFactor,
ToastManager,
toast,
TabBar,
};