handling collapse state in a more simple and consistence way

This commit is contained in:
Sebastian Sdorra
2020-03-31 08:26:01 +02:00
parent ca39a5b453
commit 2821005d8c
11 changed files with 178 additions and 153 deletions

View File

@@ -30,6 +30,8 @@ type Props = {
label: string;
};
// TODO is it used in the menu? should it use MenuContext for collapse state?
const ExternalLink: FC<Props> = ({ to, icon, label }) => {
let showIcon;
if (icon) {

View File

@@ -0,0 +1,73 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useContext, useState } from "react";
const MENU_COLLAPSED = "secondary-menu-collapsed";
export type MenuContext = {
isCollapsed: () => boolean;
setCollapsed: (collapsed: boolean) => void;
};
export const LocalStorageMenuContextProvider: FC = ({children}) => {
const [state, setState] = useState(localStorage.getItem(MENU_COLLAPSED) === "true");
const context = {
isCollapsed() {
return state;
},
setCollapsed(collapsed: boolean) {
localStorage.setItem(MENU_COLLAPSED, String(collapsed));
setState(collapsed);
}
};
return <MenuContext.Provider value={context}>{children}</MenuContext.Provider>;
};
export const MenuContext = React.createContext<MenuContext>({
isCollapsed() {
return false;
},
setCollapsed() {}
});
export const StateMenuContextProvider: FC = ({children}) => {
const [collapsed, setCollapsed] = useState(false);
const context = {
isCollapsed() {
return collapsed;
},
setCollapsed
};
return <MenuContext.Provider value={context}>{children}</MenuContext.Provider>;
};
const useMenuContext = () => {
return useContext<MenuContext>(MenuContext);
};
export default useMenuContext;

View File

@@ -29,6 +29,8 @@ type Props = {
action: () => void;
};
// TODO is it used in the menu? should it use MenuContext for collapse state?
class NavAction extends React.Component<Props> {
render() {
const { label, icon, action } = this.props;

View File

@@ -23,60 +23,44 @@
*/
import * as React from "react";
import classNames from "classnames";
import { Link, Route } from "react-router-dom";
import { Link, useRouteMatch } from "react-router-dom";
import { RoutingProps } from "./RoutingProps";
import { FC } from "react";
import { useContext } from "react";
import useMenuContext, { MenuContext } from "./MenuContext";
// TODO mostly copy of PrimaryNavigationLink
type Props = {
to: string;
icon?: string;
type Props = RoutingProps & {
label: string;
activeOnlyWhenExact?: boolean;
activeWhenMatch?: (route: any) => boolean;
collapsed?: boolean;
title?: string;
icon?: string;
};
class NavLink extends React.Component<Props> {
static defaultProps = {
activeOnlyWhenExact: true
};
const NavLink: FC<Props> = ({ to, activeOnlyWhenExact, icon, label, title }) => {
const match = useRouteMatch({
path: to,
exact: activeOnlyWhenExact
});
isActive(route: any) {
const { activeWhenMatch } = this.props;
return route.match || (activeWhenMatch && activeWhenMatch(route));
}
const context = useMenuContext();
const collapsed = context.isCollapsed();
renderLink = (route: any) => {
const { to, icon, label, collapsed, title } = this.props;
let showIcon = null;
if (icon) {
showIcon = (
<>
<i className={classNames(icon, "fa-fw")} />{" "}
</>
);
}
return (
<li title={collapsed ? title : undefined}>
<Link
className={classNames(this.isActive(route) ? "is-active" : "", collapsed ? "has-text-centered" : "")}
to={to}
>
{showIcon}
{collapsed ? null : label}
</Link>
</li>
let showIcon = null;
if (icon) {
showIcon = (
<>
<i className={classNames(icon, "fa-fw")} />{" "}
</>
);
};
render() {
const { to, activeOnlyWhenExact } = this.props;
return <Route path={to} exact={activeOnlyWhenExact} children={this.renderLink} />;
}
}
return (
<li title={collapsed ? title : undefined}>
<Link className={classNames(!!match ? "is-active" : "", collapsed ? "has-text-centered" : "")} to={to}>
{showIcon}
{collapsed ? null : label}
</Link>
</li>
);
};
export default NavLink;

View File

@@ -27,6 +27,8 @@ type Props = {
children?: ReactNode;
};
// TODO it is used?
class Navigation extends React.Component<Props> {
render() {
return <aside className="menu">{this.props.children}</aside>;

View File

@@ -22,18 +22,8 @@
* SOFTWARE.
*/
import React from "react";
const MENU_COLLAPSED = "secondary-menu-collapsed";
export const MenuContext = React.createContext({
menuCollapsed: isMenuCollapsed(),
setMenuCollapsed: (collapsed: boolean) => {}
});
export function isMenuCollapsed() {
return localStorage.getItem(MENU_COLLAPSED) === "true";
}
export function storeMenuCollapsed(status: boolean) {
localStorage.setItem(MENU_COLLAPSED, String(status));
export type RoutingProps = {
to: string;
activeOnlyWhenExact?: boolean;
activeWhenMatch?: (route: any) => boolean;
}

View File

@@ -30,6 +30,7 @@ import styled from "styled-components";
import SubNavigation from "./SubNavigation";
import { Binder, ExtensionPoint, BinderContext } from "@scm-manager/ui-extensions";
import { MemoryRouter } from "react-router-dom";
import { StateMenuContextProvider } from "./MenuContext";
const Columns = styled.div`
margin: 2rem;
@@ -52,6 +53,7 @@ const withRoute = (route: string) => {
};
storiesOf("Navigation|Secondary", module)
.addDecorator(story => <StateMenuContextProvider>{story()}</StateMenuContextProvider>)
.addDecorator(story => (
<Columns className="columns">
<div className="column is-3">{story()}</div>
@@ -59,42 +61,26 @@ storiesOf("Navigation|Secondary", module)
))
.add("Default", () =>
withRoute("/")(
<SecondaryNavigation label="Hitchhiker" collapsed={false} onCollapse={() => {}}>
<SecondaryNavigationItem to="/" icon="fas fa-puzzle-piece" label="Puzzle 42" title="Puzzle 42" />
<SecondaryNavigationItem to="/some" icon="fas fa-star" label="Heart Of Gold" title="Heart Of Gold" />
</SecondaryNavigation>
)
)
.add("Collapsed", () =>
withRoute("/")(
<SecondaryNavigation label="Hitchhiker" collapsed={true} onCollapse={() => {}}>
<SecondaryNavigationItem to="/" icon="fas fa-puzzle-piece" label="Puzzle 42" title="Puzzle 42" />
<SecondaryNavigationItem to="/some" icon="fas fa-star" label="Heart Of Gold" title="Heart Of Gold" />
<SecondaryNavigation label="Hitchhiker">
<SecondaryNavigationItem to="/42" icon="fas fa-puzzle-piece" label="Puzzle 42" title="Puzzle 42" />
<SecondaryNavigationItem to="/heart-of-gold" icon="fas fa-star" label="Heart Of Gold" title="Heart Of Gold" />
</SecondaryNavigation>
)
)
.add("Sub Navigation", () =>
withRoute("/")(
<SecondaryNavigation label="Hitchhiker" collapsed={false} onCollapse={() => {}}>
<SecondaryNavigation label="Hitchhiker">
<SecondaryNavigationItem to="/42" icon="fas fa-puzzle-piece" label="Puzzle 42" title="Puzzle 42" />
{starships}
</SecondaryNavigation>
)
)
.add("Sub Navigation Collapsed", () =>
withRoute("/hitchhiker/starships/heart-of-gold")(
<SecondaryNavigation label="Hitchhiker" collapsed={true} onCollapse={() => {}}>
<SecondaryNavigationItem to="/42" icon="fas fa-puzzle-piece" label="Puzzle 42" title="Puzzle 42" />
{starships}
</SecondaryNavigation>
)
)
.add("Collapsed EP Sub", () => {
.add("Extension Point", () => {
const binder = new Binder("menu");
binder.bind("subnav.sample", starships);
return withRoute("/hitchhiker/starships/titanic")(
<BinderContext.Provider value={binder}>
<SecondaryNavigation label="Hitchhiker" collapsed={true} onCollapse={() => {}}>
<SecondaryNavigation label="Hitchhiker">
<SecondaryNavigationItem to="/42" icon="fas fa-puzzle-piece" label="Puzzle 42" title="Puzzle 42" />
<ExtensionPoint name="subnav.sample" renderAll={true} />
</SecondaryNavigation>

View File

@@ -21,18 +21,17 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, ReactElement, ReactNode, useContext, useEffect } from "react";
import React, { FC, ReactElement, ReactNode} from "react";
import styled from "styled-components";
import SubNavigation from "./SubNavigation";
import { matchPath, useLocation } from "react-router-dom";
import { isMenuCollapsed, MenuContext } from "./MenuContext";
import useMenuContext from "./MenuContext";
import { ExtensionPoint, Binder, useBinder } from "@scm-manager/ui-extensions";
import { RoutingProps } from "./RoutingProps";
type Props = {
label: string;
children: ReactElement[];
collapsed: boolean;
onCollapse?: (newStatus: boolean) => void;
};
type CollapsedProps = {
@@ -61,32 +60,14 @@ const MenuLabel = styled.p<CollapsedProps>`
cursor: pointer;
`;
const SecondaryNavigation: FC<Props> = ({ label, children, collapsed, onCollapse }) => {
const SecondaryNavigation: FC<Props> = ({ label, children}) => {
const location = useLocation();
const binder = useBinder();
const menuContext = useContext(MenuContext);
const menuContext = useMenuContext();
const subNavActive = isSubNavigationActive(binder, children, location.pathname);
const isCollapsed = collapsed && !subNavActive;
const isCollapsed = menuContext.isCollapsed();
useEffect(() => {
if (isMenuCollapsed()) {
menuContext.setMenuCollapsed(!subNavActive);
}
}, [subNavActive]);
const childrenWithProps = React.Children.map(children, (child: ReactElement) =>
React.cloneElement(child, {
collapsed: isCollapsed,
propTransformer: (props: object) => {
const np = {
...props,
collapsed: isCollapsed
};
return np;
}
})
);
const arrowIcon = isCollapsed ? <i className="fas fa-caret-down" /> : <i className="fas fa-caret-right" />;
return (
@@ -95,16 +76,16 @@ const SecondaryNavigation: FC<Props> = ({ label, children, collapsed, onCollapse
<MenuLabel
className="menu-label"
collapsed={isCollapsed}
onClick={onCollapse && !subNavActive ? () => onCollapse(!isCollapsed) : undefined}
onClick={!subNavActive ? () => menuContext.setCollapsed(!isCollapsed) : undefined}
>
{onCollapse && !subNavActive && (
{!subNavActive && (
<Icon color="info" className="is-medium" collapsed={isCollapsed}>
{arrowIcon}
</Icon>
)}
{isCollapsed ? "" : label}
</MenuLabel>
<ul className="menu-list">{childrenWithProps}</ul>
<ul className="menu-list">{children}</ul>
</div>
</SectionContainer>
);
@@ -116,32 +97,42 @@ const createParentPath = (to: string) => {
return parents.join("/");
};
const expandExtensionPoints = (binder: Binder, child: ReactElement): Array<ReactElement> => {
// @ts-ignore
if (child.type.name === ExtensionPoint.name) {
// @ts-ignore
return binder.getExtensions(child.props.name, child.props.props);
}
return [child];
};
const mapToProps = (child: ReactElement<RoutingProps>) => {
return child.props;
};
const isSubNavigation = (child: ReactElement) => {
// @ts-ignore
return child.type.name === SubNavigation.name;
};
const isActive = (url: string, props: RoutingProps) => {
const path = createParentPath(props.to);
const matches = matchPath(url, {
path,
exact: props.activeOnlyWhenExact
});
return matches != null;
};
const isSubNavigationActive = (binder: Binder, children: ReactNode, url: string): boolean => {
const childArray = React.Children.toArray(children);
const match = childArray
.filter(React.isValidElement)
.flatMap(child => {
// @ts-ignore
if (child.type.name === ExtensionPoint.name) {
// @ts-ignore
return binder.getExtensions(child.props.name, child.props.props);
}
return [child];
})
.filter(child => {
return child.type.name === SubNavigation.name;
})
.map(child => {
return child.props;
})
.find(props => {
const path = createParentPath(props.to);
const matches = matchPath(url, {
path,
exact: props.activeOnlyWhenExact as boolean
});
return matches != null;
});
.flatMap(child => expandExtensionPoints(binder, child))
.filter(isSubNavigation)
.map(mapToProps)
.find(props => isActive(url, props));
return match != null;
};

View File

@@ -21,20 +21,15 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, ReactElement, ReactNode } from "react";
import { MenuContext } from "./MenuContext";
import React, {FC} from "react";
import SubNavigation from "./SubNavigation";
import NavLink from "./NavLink";
import {RoutingProps} from "./RoutingProps";
type Props = {
to: string;
icon?: string;
type Props = RoutingProps & {
label: string;
title: string;
collapsed?: boolean;
activeWhenMatch?: (route: any) => boolean;
activeOnlyWhenExact?: boolean;
children?: ReactElement[];
title?: string;
icon?: string;
};
const SecondaryNavigationItem: FC<Props> = ({ children, ...props }) => {

View File

@@ -21,22 +21,19 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, ReactElement, useContext, useEffect } from "react";
import React, { FC, useContext} from "react";
import { Link, useRouteMatch } from "react-router-dom";
import classNames from "classnames";
import useMenuContext, {MenuContext} from "./MenuContext";
import {RoutingProps} from "./RoutingProps";
type Props = {
to: string;
icon?: string;
type Props = RoutingProps & {
label: string;
activeOnlyWhenExact?: boolean;
activeWhenMatch?: (route: any) => boolean;
children?: ReactElement[];
collapsed?: boolean;
title?: string;
icon?: string;
};
const SubNavigation: FC<Props> = ({ to, activeOnlyWhenExact, icon, collapsed, title, label, children }) => {
const SubNavigation: FC<Props> = ({ to, activeOnlyWhenExact, icon, title, label, children }) => {
const parents = to.split("/");
parents.splice(-1, 1);
const parent = parents.join("/");
@@ -46,6 +43,9 @@ const SubNavigation: FC<Props> = ({ to, activeOnlyWhenExact, icon, collapsed, ti
exact: activeOnlyWhenExact
});
const context = useMenuContext();
const collapsed = context.isCollapsed();
let defaultIcon = "fas fa-cog";
if (icon) {
defaultIcon = icon;

View File

@@ -31,5 +31,5 @@ export { default as SubNavigation } from "./SubNavigation";
export { default as PrimaryNavigation } from "./PrimaryNavigation";
export { default as PrimaryNavigationLink } from "./PrimaryNavigationLink";
export { default as SecondaryNavigation } from "./SecondaryNavigation";
export { MenuContext, storeMenuCollapsed, isMenuCollapsed } from "./MenuContext";
export { MenuContext, LocalStorageMenuContextProvider } from "./MenuContext";
export { default as SecondaryNavigationItem } from "./SecondaryNavigationItem";