Skip to content

Commit

Permalink
[Serverless nav] Accordion auto-expand state + polish work (#169651)
Browse files Browse the repository at this point in the history
  • Loading branch information
sebelga authored Oct 26, 2023
1 parent 8e59304 commit b759b8f
Show file tree
Hide file tree
Showing 14 changed files with 208 additions and 84 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const ProjectNavigation: React.FC<{
onCollapseToggle={onCollapseToggle}
css={
isCollapsed
? { display: 'none;' }
? undefined
: { overflow: 'visible', clipPath: 'polygon(0 0, 300% 0, 300% 100%, 0 100%)' }
}
>
Expand Down
18 changes: 15 additions & 3 deletions packages/core/chrome/core-chrome-browser/src/project_navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import type { ComponentType } from 'react';
import type { Location } from 'history';
import type { EuiAccordionProps, EuiThemeSizes, IconType } from '@elastic/eui';
import type { EuiThemeSizes, IconType } from '@elastic/eui';
import type { AppId as DevToolsApp, DeepLinkId as DevToolsLink } from '@kbn/deeplinks-devtools';
import type {
AppId as AnalyticsApp,
Expand Down Expand Up @@ -112,16 +112,28 @@ interface NodeDefinitionBase {
* @default 'block'
*/
renderAs?: RenderAs;
/**
* ["group" nodes only] Flag to indicate if the group is initially collapsed or not.
*
* `undefined`: (Recommended) the group will be opened if any of its children nodes matches the current URL.
*
* `false`: the group will be opened event if none of its children nodes matches the current URL.
*
* `true`: the group will be collapsed event if any of its children nodes matches the current URL.
*/
defaultIsCollapsed?: boolean;
/**
* ["group" nodes only] Optional flag to indicate if a horizontal rule should be rendered after the node.
* Note: this property is currently only used for (1) "group" nodes and (2) in the navigation
* panel opening on the right of the side nav.
*/
appendHorizontalRule?: boolean;
/**
* ["group" nodes only] Temp prop. Will be removed once the new navigation is fully implemented.
* ["group" nodes only] Flag to indicate if the accordion is collapsible.
* Must be used with `renderAs` set to `"accordion"`
* @default `true`
*/
accordionProps?: Partial<EuiAccordionProps>;
isCollapsible?: boolean;
/**
* ----------------------------------------------------------------------------------------------
* -------------------------------- ITEM NODES ONLY PROPS ---------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export interface Props<
ChildrenId extends string = Id
> extends NodeProps<LinkId, Id, ChildrenId> {
unstyled?: boolean;
defaultIsCollapsed?: boolean;
}

function NavigationGroupInternalComp<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,14 @@ function NavigationItemComp<

if (isRootLevel) {
const href = getNavigationNodeHref(navNode);

return (
<EuiCollapsibleNavItem
id={navNode.id}
title={navNode.title}
icon={navNode.icon}
iconProps={{ size: 'm' }}
isSelected={navNode.isActive}
data-test-subj={`nav-item-${navNode.id}`}
linkProps={{
href,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ import {
} from '@elastic/eui';
import type { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser';
import type { NavigateToUrlFn } from '../../../types/internal';
import { usePanel } from './panel';
import { nodePathToString } from '../../utils';
import { useNavigation as useServices } from '../../services';
import { usePanel } from './panel';

const getStyles = (euiTheme: EuiThemeComputed<{}>) => css`
* {
Expand All @@ -51,11 +52,12 @@ interface Props {
export const NavigationItemOpenPanel: FC<Props> = ({ item, navigateToUrl }: Props) => {
const { euiTheme } = useEuiTheme();
const { open: openPanel, close: closePanel, selectedNode } = usePanel();
const { isSideNavCollapsed } = useServices();
const { title, deepLink, isActive, children } = item;
const id = nodePathToString(item);
const href = deepLink?.url ?? item.href;
const isNotMobile = useIsWithinMinBreakpoint('s');
const isIconVisible = isNotMobile && !!children && children.length > 0;
const isIconVisible = isNotMobile && !isSideNavCollapsed && !!children && children.length > 0;

const itemClassNames = classNames(
'sideNavItem',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
* Side Public License, v 1.
*/

import React, { FC } from 'react';

import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import { css } from '@emotion/css';
import {
EuiTitle,
EuiCollapsibleNavItem,
Expand All @@ -26,13 +27,15 @@ import { nodePathToString, isAbsoluteLink, getNavigationNodeHref } from '../../u
import { PanelContext, usePanel } from './panel';
import { NavigationItemOpenPanel } from './navigation_item_open_panel';

const DEFAULT_SPACE_BETWEEN_LEVEL_1_GROUPS: EuiThemeSize = 'm';
const DEFAULT_IS_COLLAPSED = true;
const DEFAULT_IS_COLLAPSIBLE = true;

const nodeHasLink = (navNode: ChromeProjectNavigationNode) =>
Boolean(navNode.deepLink) || Boolean(navNode.href);

const nodeHasChildren = (navNode: ChromeProjectNavigationNode) => Boolean(navNode.children?.length);

const DEFAULT_SPACE_BETWEEN_LEVEL_1_GROUPS: EuiThemeSize = 'm';

/**
* Predicate to determine if a node should be visible in the main side nav.
* If it is not visible it will be filtered out and not rendered.
Expand Down Expand Up @@ -103,7 +106,6 @@ const renderBlockTitle: (
css={({ euiTheme }: any) => {
return {
marginTop: spaceBefore ? euiTheme.size[spaceBefore] : undefined,
// marginTop: euiTheme.size.base,
paddingBlock: euiTheme.size.xs,
paddingInline: euiTheme.size.s,
};
Expand Down Expand Up @@ -148,12 +150,14 @@ const nodeToEuiCollapsibleNavProps = (
closePanel,
isSideNavCollapsed,
treeDepth,
itemsState,
}: {
navigateToUrl: NavigateToUrlFn;
openPanel: PanelContext['open'];
closePanel: PanelContext['close'];
isSideNavCollapsed: boolean;
treeDepth: number;
itemsState: AccordionItemsState;
}
): {
items: Array<EuiCollapsibleNavItemProps | EuiCollapsibleNavSubItemProps>;
Expand All @@ -172,7 +176,11 @@ const nodeToEuiCollapsibleNavProps = (
spaceBefore: _spaceBefore,
} = navNode;
const isExternal = Boolean(href) && isAbsoluteLink(href!);
const isSelected = hasChildren && !isItem ? false : isActive;

const isAccordion = hasChildren && !isItem;
const isAccordionExpanded = (itemsState[id]?.isCollapsed ?? DEFAULT_IS_COLLAPSED) === false;
const isSelected = isAccordion && isAccordionExpanded ? false : isActive;

const dataTestSubj = classnames(`nav-item`, `nav-item-${id}`, {
[`nav-item-deepLinkId-${deepLink?.id}`]: !!deepLink,
[`nav-item-id-${id}`]: id,
Expand Down Expand Up @@ -219,6 +227,7 @@ const nodeToEuiCollapsibleNavProps = (
closePanel,
isSideNavCollapsed,
treeDepth: treeDepth + 1,
itemsState,
})
)
.filter(({ isVisible }) => isVisible)
Expand All @@ -244,13 +253,6 @@ const nodeToEuiCollapsibleNavProps = (
}
: undefined;

const accordionProps: Partial<EuiAccordionProps> | undefined = isItem
? undefined
: {
initialIsOpen: treeDepth === 0 ? isActive : true, // FIXME open state is controlled on component mount
...navNode.accordionProps,
};

if (renderAs === 'block' && treeDepth > 0 && subItems) {
// Render as a group block (bold title + list of links underneath)
return {
Expand All @@ -267,7 +269,6 @@ const nodeToEuiCollapsibleNavProps = (
id,
title,
isSelected,
accordionProps,
linkProps,
onClick,
href,
Expand All @@ -290,6 +291,16 @@ const nodeToEuiCollapsibleNavProps = (
return { items, isVisible };
};

interface AccordionItemsState {
[navNodeId: string]: {
isCollapsible: boolean;
isCollapsed: boolean;
// We want to auto expand the group automatically if the node is active (URL match)
// but once the user manually expand a group we don't want to close it afterward automatically.
doCollapseFromActiveState: boolean;
};
}

interface Props {
navNode: ChromeProjectNavigationNode;
}
Expand All @@ -298,23 +309,161 @@ export const NavigationSectionUI: FC<Props> = ({ navNode }) => {
const { navigateToUrl, isSideNavCollapsed } = useServices();
const { open: openPanel, close: closePanel } = usePanel();

const { items, isVisible } = nodeToEuiCollapsibleNavProps(navNode, {
navigateToUrl,
openPanel,
closePanel,
isSideNavCollapsed,
treeDepth: 0,
const navNodesById = useMemo(() => {
const byId = {
[nodePathToString(navNode)]: navNode,
};

const parse = (navNodes?: ChromeProjectNavigationNode[]) => {
if (!navNodes) return;
navNodes.forEach((childNode) => {
byId[nodePathToString(childNode)] = childNode;
parse(childNode.children);
});
};
parse(navNode.children);

return byId;
}, [navNode]);

const [itemsState, setItemsState] = useState<AccordionItemsState>(() => {
return Object.entries(navNodesById).reduce<AccordionItemsState>((acc, [_id, node]) => {
if (node.children) {
acc[_id] = {
isCollapsed: !node.isActive ?? DEFAULT_IS_COLLAPSED,
isCollapsible: node.isCollapsible ?? DEFAULT_IS_COLLAPSIBLE,
doCollapseFromActiveState: true,
};
}
return acc;
}, {});
});

const [subItems, setSubItems] = useState<EuiCollapsibleNavSubItemProps[] | undefined>();

const toggleAccordion = useCallback((id: string) => {
setItemsState((prev) => {
const prevValue = prev[id]?.isCollapsed ?? DEFAULT_IS_COLLAPSED;
return {
...prev,
[id]: {
...prev[id],
isCollapsed: !prevValue,
doCollapseFromActiveState: false, // once we manually toggle we don't want to auto-close it when URL changes
},
};
});
}, []);

const setAccordionProps = useCallback(
(
id: string,
_accordionProps?: Partial<EuiAccordionProps>
): Partial<EuiAccordionProps> | undefined => {
const isCollapsed = itemsState[id]?.isCollapsed ?? DEFAULT_IS_COLLAPSED;
const isCollapsible = itemsState[id]?.isCollapsible ?? DEFAULT_IS_COLLAPSIBLE;

let forceState: EuiAccordionProps['forceState'] = isCollapsed ? 'closed' : 'open';
if (!isCollapsible) forceState = 'open'; // Allways open if the accordion is not collapsible

const arrowProps: EuiAccordionProps['arrowProps'] = {
css: isCollapsible ? undefined : { display: 'none' },
'data-test-subj': classNames(`accordionArrow`, `accordionArrow-${id}`),
};

const updated: Partial<EuiAccordionProps> = {
..._accordionProps,
arrowProps,
forceState,
onToggle: () => {
toggleAccordion(id);
},
};

return updated;
},
[itemsState, toggleAccordion]
);

const { items, isVisible } = useMemo(() => {
return nodeToEuiCollapsibleNavProps(navNode, {
navigateToUrl,
openPanel,
closePanel,
isSideNavCollapsed,
treeDepth: 0,
itemsState,
});
}, [closePanel, isSideNavCollapsed, navNode, navigateToUrl, openPanel, itemsState]);

const [props] = items;
const { items: accordionItems } = props;

if (!isEuiCollapsibleNavItemProps(props)) {
throw new Error(`Invalid EuiCollapsibleNavItem props for node ${props.id}`);
}

/**
* Effect to set our internal state of each of the accordions (isCollapsed) based on the
* "isActive" state of the navNode.
*/
useEffect(() => {
setItemsState((prev) => {
return Object.entries(navNodesById).reduce<AccordionItemsState>((acc, [_id, node]) => {
if (node.children && (!prev[_id] || prev[_id].doCollapseFromActiveState)) {
acc[_id] = {
isCollapsed: !node.isActive ?? DEFAULT_IS_COLLAPSED,
isCollapsible: node.isCollapsible ?? DEFAULT_IS_COLLAPSIBLE,
doCollapseFromActiveState: true,
};
}
return acc;
}, prev);
});
}, [navNodesById]);

useEffect(() => {
// Serializer to add recursively the accordionProps to each of the items
// that will control its "open"/"closed" state + handler to toggle the state.
const serializeAccordionItems = (
_items?: EuiCollapsibleNavSubItemProps[]
): EuiCollapsibleNavSubItemProps[] | undefined => {
if (!_items) return;

return _items.map((item: EuiCollapsibleNavSubItemProps) => {
if (item.renderItem) {
return item;
}
const parsed: EuiCollapsibleNavSubItemProps = {
...item,
items: serializeAccordionItems(item.items),
accordionProps: setAccordionProps(item.id!, item.accordionProps),
};
return parsed;
});
};

setSubItems(serializeAccordionItems(accordionItems));
}, [accordionItems, setAccordionProps]);

if (!isVisible) {
return null;
}

return <EuiCollapsibleNavItem {...props} />;
return (
<EuiCollapsibleNavItem
{...props}
// We add this css to prevent showing the outline when the page load when the
// accordion is auto-expanded if one of its children is active
className={css`
.euiAccordion__childWrapper,
.euiAccordion__children,
.euiCollapsibleNavAccordion__children {
outline: none;
}
`}
items={subItems}
accordionProps={setAccordionProps(navNode.id)}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -631,9 +631,7 @@ const navigationDefinitionWithPanel: ProjectNavigationDefinition<any> = {
title: 'Example project',
icon: 'logoObservability',
defaultIsCollapsed: false,
accordionProps: {
arrowProps: { css: { display: 'none' } },
},
isCollapsible: false,
children: [
{
link: 'item1',
Expand Down
Loading

0 comments on commit b759b8f

Please sign in to comment.