mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-10 07:25:44 +01:00
handling collapse state in a more simple and consistence way
This commit is contained in:
@@ -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) {
|
||||
|
||||
73
scm-ui/ui-components/src/navigation/MenuContext.tsx
Normal file
73
scm-ui/ui-components/src/navigation/MenuContext.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user