gatsby-theme-carbon 4.0.6 → 4.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/gatsby-node.mjs CHANGED
@@ -103,9 +103,14 @@ export const createSchemaCustomization = ({ actions, schema }) => {
103
103
  title: String
104
104
  path: String!
105
105
  }
106
+ type NavItemsSubLevelYaml {
107
+ title: String
108
+ path: String
109
+ pages: [NavItemsYamlPage]
110
+ }
106
111
  type NavItemsYaml implements Node {
107
112
  title: String!
108
- pages: [NavItemsYamlPage]!
113
+ pages: [NavItemsSubLevelYaml]!
109
114
  hasDivider: Boolean
110
115
  }`,
111
116
  schema.buildObjectType({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gatsby-theme-carbon",
3
- "version": "4.0.6",
3
+ "version": "4.0.7",
4
4
  "main": "index.js",
5
5
  "author": "vpicone <vp@vincepic.one> (@vpicone)",
6
6
  "repository": {
@@ -1,4 +1,10 @@
1
- import React, { useContext, useRef, useEffect } from 'react';
1
+ import React, {
2
+ useContext,
3
+ useRef,
4
+ useEffect,
5
+ useCallback,
6
+ useState,
7
+ } from 'react';
2
8
  import { SideNav, SideNavItems } from '@carbon/react';
3
9
  import { useNavItems } from '../../util/NavItems';
4
10
 
@@ -9,6 +15,7 @@ import LeftNavResourceLinks from './ResourceLinks';
9
15
  import LeftNavWrapper from './LeftNavWrapper';
10
16
  import * as styles from './LeftNav.module.scss';
11
17
  import useMetadata from '../../util/hooks/useMetadata';
18
+ import LeftNavTree from './LeftNavTree';
12
19
 
13
20
  const LeftNav = (props) => {
14
21
  const {
@@ -18,24 +25,57 @@ const LeftNav = (props) => {
18
25
  toggleNavState,
19
26
  } = useContext(NavContext);
20
27
 
28
+ const [isTreeView, setIsTreeView] = useState();
29
+
21
30
  const sideNavRef = useRef();
22
31
  const sideNavListRef = useRef();
23
32
 
33
+ const navItems = useNavItems();
34
+
35
+ const hasNestedLevels = useCallback(() => {
36
+ let nestedLevels = false;
37
+
38
+ navItems.forEach((navItem) => {
39
+ navItem.pages?.forEach((levelTwoNavItem) => {
40
+ if (levelTwoNavItem.pages && levelTwoNavItem.pages.length > 1) {
41
+ nestedLevels = true;
42
+ }
43
+ // if it is branch node with only one leaf node, convert it to a leaf node
44
+ else if (levelTwoNavItem.pages && levelTwoNavItem.pages.length) {
45
+ levelTwoNavItem.path = levelTwoNavItem.pages[0].path;
46
+ levelTwoNavItem.pages = null;
47
+ }
48
+ });
49
+ });
50
+ return nestedLevels;
51
+ }, [navItems]);
52
+
24
53
  useEffect(() => {
25
- sideNavListRef.current = sideNavRef.current.querySelector('.sidenav-list');
26
- }, []);
54
+ setIsTreeView(hasNestedLevels());
55
+ }, [navItems]);
27
56
 
28
57
  useEffect(() => {
29
- sideNavListRef.current.addEventListener('scroll', (e) => {
30
- setLeftNavScrollTop(e.target.scrollTop);
31
- });
32
- }, [setLeftNavScrollTop]);
58
+ if (!isTreeView) {
59
+ sideNavListRef.current =
60
+ sideNavRef.current.querySelector('.sidenav-list');
61
+ }
62
+ }, [isTreeView]);
63
+
64
+ useEffect(() => {
65
+ if (!isTreeView) {
66
+ sideNavListRef.current.addEventListener('scroll', (e) => {
67
+ setLeftNavScrollTop(e.target.scrollTop);
68
+ });
69
+ }
70
+ }, [setLeftNavScrollTop, isTreeView]);
33
71
 
34
72
  useEffect(() => {
35
- if (leftNavScrollTop >= 0 && !sideNavListRef?.current.scrollTop) {
36
- sideNavListRef.current.scrollTop = leftNavScrollTop;
73
+ if (!isTreeView) {
74
+ if (leftNavScrollTop >= 0 && !sideNavListRef?.current.scrollTop) {
75
+ sideNavListRef.current.scrollTop = leftNavScrollTop;
76
+ }
37
77
  }
38
- }, [leftNavScrollTop]);
78
+ }, [leftNavScrollTop, isTreeView]);
39
79
 
40
80
  const getLeftNavClassNames = () => {
41
81
  if (props.theme === 'dark') {
@@ -44,7 +84,6 @@ const LeftNav = (props) => {
44
84
  return styles.sideNavWhite;
45
85
  };
46
86
 
47
- const navItems = useNavItems();
48
87
  const { navigationStyle } = useMetadata();
49
88
 
50
89
  const closeSwitcher = () => {
@@ -57,25 +96,30 @@ const LeftNav = (props) => {
57
96
  expanded={leftNavIsOpen}
58
97
  onClick={closeSwitcher}
59
98
  onKeyPress={closeSwitcher}>
60
- <SideNav
61
- ref={sideNavRef}
62
- aria-label="Side navigation"
63
- expanded={navigationStyle ? leftNavIsOpen : true}
64
- defaultExpanded={!navigationStyle}
65
- isPersistent={!navigationStyle}
66
- className={getLeftNavClassNames()}>
67
- <SideNavItems className="sidenav-list">
68
- {navItems.map((item, i) => (
69
- <LeftNavItem
70
- items={item.pages}
71
- category={item.title}
72
- key={i}
73
- hasDivider={item.hasDivider}
74
- />
75
- ))}
76
- <LeftNavResourceLinks />
77
- </SideNavItems>
78
- </SideNav>
99
+ {isTreeView ? (
100
+ <LeftNavTree items={navItems} theme={props.theme} />
101
+ ) : (
102
+ <SideNav
103
+ ref={sideNavRef}
104
+ aria-label="Side navigation"
105
+ expanded={navigationStyle ? leftNavIsOpen : true}
106
+ defaultExpanded={!navigationStyle}
107
+ isPersistent={!navigationStyle}
108
+ className={getLeftNavClassNames()}>
109
+ <SideNavItems className="sidenav-list">
110
+ {typeof isTreeView !== 'undefined' &&
111
+ navItems.map((item, i) => (
112
+ <LeftNavItem
113
+ items={item.pages}
114
+ category={item.title}
115
+ key={i}
116
+ hasDivider={item.hasDivider}
117
+ />
118
+ ))}
119
+ <LeftNavResourceLinks />
120
+ </SideNavItems>
121
+ </SideNav>
122
+ )}
79
123
  </LeftNavWrapper>
80
124
  );
81
125
  };
@@ -106,7 +106,7 @@ nav.side-nav--dark a.current-item {
106
106
  }
107
107
 
108
108
  .divider-space {
109
- margin-top: 4rem;
109
+ margin-top: $spacing-05;
110
110
  margin-bottom: $spacing-05;
111
111
  }
112
112
 
@@ -0,0 +1,158 @@
1
+ import React, { memo, useCallback, useEffect, useState } from 'react';
2
+ import { useLocation } from '@reach/router';
3
+ import { Theme, TreeNode, TreeView } from '@carbon/react';
4
+ import { Link } from 'gatsby';
5
+
6
+ import cx from 'classnames';
7
+ import slugify from 'slugify';
8
+ import PropTypes from 'prop-types';
9
+ import * as styles from './LeftNavTree.module.scss';
10
+ import { dfs } from '../../util/NavTree';
11
+
12
+ import LeftNavResourceLinks from './ResourceLinks';
13
+
14
+ const LeftNavTree = ({ items, theme }) => {
15
+ const [itemNodes, setItemNodes] = useState([]);
16
+ const [treeActiveItem, setTreeActiveItem] = useState({});
17
+ const [activePath, setActivePath] = useState('');
18
+ const location = useLocation();
19
+
20
+ const themeValue = theme === 'dark' ? 'g100' : theme;
21
+
22
+ useEffect(() => {
23
+ const newItemNodeArray = [];
24
+ // method to set isBranch value for branch nodes
25
+ const assignNodeType = (item) => {
26
+ // branch node with more than 1 leaf nodes
27
+ if (item.pages && item.pages.length > 1) {
28
+ item.isBranch = true;
29
+ item.pages.forEach((SubNavItem, i) => assignNodeType(SubNavItem));
30
+ }
31
+ // if it is branch node with only one leaf node, convert it to a leaf node
32
+ else if (item.pages && item.pages.length) {
33
+ item.path = item.pages[0].path;
34
+ item.pages = null;
35
+ }
36
+ return item;
37
+ };
38
+
39
+ items.forEach((item) => {
40
+ newItemNodeArray.push(assignNodeType(item));
41
+ });
42
+
43
+ // Create hierarchical, unique node ids for all nodes in the nav tree
44
+ dfs(newItemNodeArray, (evalNode) => {
45
+ if (!evalNode.parentNodeId) {
46
+ evalNode.parentNodeId = 'left_nav_tree';
47
+ }
48
+
49
+ // Combine the parent's node id with the current node's id
50
+ const currentNodeId = evalNode.title
51
+ .toLocaleLowerCase()
52
+ .replace(/\s/g, '');
53
+ evalNode.id = `${evalNode.parentNodeId}_${currentNodeId}`;
54
+
55
+ // Set the parent node id of each child of this node to the newly generated id
56
+ evalNode.pages?.forEach((child) => {
57
+ child.parentNodeId = evalNode.id;
58
+ });
59
+
60
+ return false;
61
+ });
62
+
63
+ setItemNodes(newItemNodeArray);
64
+ }, [items]);
65
+
66
+ useEffect(() => {
67
+ const stripTrailingSlash = (str) =>
68
+ str.endsWith('/') ? str.slice(0, -1) : str;
69
+ setActivePath(stripTrailingSlash(location.pathname));
70
+ }, [location.pathname]);
71
+
72
+ const getItemPath = (item) =>
73
+ item.path || slugify(item.title, { lower: true, strict: true });
74
+
75
+ const removeHashAndQuery = (path) => path?.split('?')?.[0]?.split('#')?.[0];
76
+
77
+ const isTreeNodeActive = useCallback(
78
+ (node) => getItemPath(node) === removeHashAndQuery(activePath),
79
+ [activePath]
80
+ );
81
+
82
+ useEffect(() => {
83
+ const activeNode = dfs(itemNodes, isTreeNodeActive);
84
+ setTreeActiveItem(activeNode);
85
+ }, [isTreeNodeActive, itemNodes]);
86
+
87
+ const isTreeNodeExpanded = (node) =>
88
+ !!dfs([node], (evalNode) =>
89
+ evalNode.pages?.some((page) => page.id === treeActiveItem?.id)
90
+ );
91
+
92
+ function renderTree({ nodes }) {
93
+ if (!nodes) {
94
+ return;
95
+ }
96
+ return nodes.map((node) => {
97
+ let label = node.title;
98
+
99
+ if (node.path) {
100
+ label = (
101
+ <Link
102
+ to={node.path}
103
+ className={styles.anchor}
104
+ // tabIndex={visible ? 0 : '-1'}
105
+ >
106
+ {node.title}
107
+ </Link>
108
+ );
109
+ }
110
+
111
+ return (
112
+ <TreeNode
113
+ id={node.id}
114
+ key={node.id}
115
+ label={label}
116
+ value={node.title}
117
+ isExpanded={isTreeNodeExpanded(node)}
118
+ className={cx({
119
+ 'cds--tree-node--active': node.id === treeActiveItem?.id,
120
+ 'cds--tree-node--selected': node.id === treeActiveItem?.id,
121
+ [styles.divider]: node.hasDivider,
122
+ })}
123
+ onSelect={() => {
124
+ node.path && setTreeActiveItem(node);
125
+ }}>
126
+ {node.isBranch &&
127
+ renderTree({
128
+ nodes: node.pages,
129
+ })}
130
+ </TreeNode>
131
+ );
132
+ });
133
+ }
134
+
135
+ return (
136
+ <Theme className={styles.container} theme={themeValue}>
137
+ <TreeView label="Side navigation" hideLabel>
138
+ {renderTree({ nodes: itemNodes })}
139
+ <LeftNavResourceLinks />
140
+ </TreeView>
141
+ </Theme>
142
+ );
143
+ };
144
+
145
+ LeftNavTree.propTypes = {
146
+ items: PropTypes.arrayOf(
147
+ PropTypes.shape({
148
+ title: PropTypes.string.isRequired,
149
+ path: PropTypes.string,
150
+ pages: PropTypes.array,
151
+ hasDivider: PropTypes.bool,
152
+ })
153
+ ),
154
+ };
155
+
156
+ const areEqual = (prevProps, nextProps) => true;
157
+
158
+ export default memo(LeftNavTree, areEqual);
@@ -0,0 +1,163 @@
1
+ @use '@carbon/react/scss/breakpoint' as breakpoint;
2
+ @use '@carbon/react/scss/spacing' as spacing;
3
+ @use '@carbon/react/scss/theme' as theme;
4
+ @use '@carbon/react/scss/type' as type;
5
+ @use '@carbon/react/scss/utilities/convert' as convert;
6
+ @use '@carbon/react/scss/zone';
7
+
8
+ .container {
9
+ height: 100%;
10
+ margin: spacing.$spacing-10 0;
11
+ background-color: theme.$background;
12
+
13
+ :global(.cds--tree) {
14
+ overflow: auto;
15
+ height: calc(100% - spacing.$spacing-10);
16
+ background-color: theme.$background;
17
+ }
18
+
19
+ :global(.cds--tree-node) {
20
+ background-color: theme.$background;
21
+ }
22
+
23
+ // fix left alignment because we are hiding the left icon
24
+ :global(.cds--tree-leaf-node) {
25
+ padding-left: spacing.$spacing-05;
26
+ }
27
+
28
+ // label font and cursor overrides
29
+ :global(.cds--tree-node__label) {
30
+ position: relative;
31
+ cursor: pointer;
32
+ @include type.type-style('heading-01');
33
+ }
34
+
35
+ // override g10 selected background
36
+ :global(.cds--tree-node--selected > .cds--tree-node__label),
37
+ :global(.cds--tree-node--selected > .cds--tree-node__label::before) {
38
+ background-color: transparent;
39
+ }
40
+
41
+ // bold selected labels and make darker
42
+ :global(.cds--tree-node[aria-expanded='true'] > .cds--tree-node__label) {
43
+ color: theme.$text-primary;
44
+ @include type.type-style('heading-01');
45
+ }
46
+
47
+ // override g10 hover background
48
+ :global(.cds--tree-node__label:hover),
49
+ :global(.cds--tree-node--selected > .cds--tree-node__label:hover) {
50
+ background-color: theme.$layer-02;
51
+ }
52
+
53
+ // child labels have normal font weight
54
+ :global(.cds--tree-node__children .cds--tree-node__label) {
55
+ @include type.type-style('body-compact-01');
56
+ }
57
+
58
+ // move all div padding to the anchor
59
+ :global(.cds--tree-node:not(.cds--tree-parent-node) .cds--tree-node__label) {
60
+ padding-top: 0;
61
+ padding-bottom: 0;
62
+ }
63
+
64
+ // anchor links fill entire label box
65
+ .anchor {
66
+ width: calc(100% + spacing.$spacing-05);
67
+ padding: 0.375rem spacing.$spacing-05;
68
+ margin-left: -(spacing.$spacing-05);
69
+ color: theme.$text-secondary;
70
+ text-decoration: none;
71
+ }
72
+
73
+ // anchor links are darker on hover
74
+ .anchor:hover {
75
+ color: theme.$text-primary;
76
+ }
77
+
78
+ // nested anchor link
79
+ :global(.cds--tree-node__children) .anchor {
80
+ width: calc(100% + spacing.$spacing-07);
81
+ padding-left: spacing.$spacing-07;
82
+ margin-left: -(spacing.$spacing-07);
83
+ }
84
+
85
+ // have anchor selected match label selected
86
+ :global(.cds--tree-node--selected > .cds--tree-node__label) .anchor {
87
+ position: relative;
88
+ background: theme.$background-selected;
89
+ color: theme.$text-primary;
90
+ @include type.type-style('heading-01');
91
+
92
+ &::before {
93
+ position: absolute;
94
+ top: 0;
95
+ left: 0;
96
+ width: 0.25rem;
97
+ height: 100%;
98
+ background-color: theme.$border-interactive;
99
+ content: '';
100
+ }
101
+ }
102
+
103
+ // nested 2 deep anchor link
104
+ :global(.cds--tree-node__children > li > .cds--tree-node__children) .anchor {
105
+ width: calc(100% + spacing.$spacing-09);
106
+ padding-left: spacing.$spacing-09;
107
+ margin-left: -(spacing.$spacing-09);
108
+ }
109
+
110
+ // expand toggle click target
111
+ :global(.cds--tree-parent-node__toggle) {
112
+ position: absolute;
113
+ top: 0;
114
+ right: 0;
115
+ display: block;
116
+ width: 100%;
117
+ height: 100%;
118
+ margin-top: 0;
119
+ margin-right: 0;
120
+ }
121
+
122
+ // custom icon
123
+ :global(.cds--tree-parent-node__toggle::after) {
124
+ position: absolute;
125
+ top: spacing.$spacing-03;
126
+ right: spacing.$spacing-05;
127
+ display: block;
128
+ width: convert.rem(16px);
129
+ height: convert.rem(16px);
130
+ background-color: theme.$icon-primary;
131
+ mask: url("data:image/svg+xml,%3Csvg focusable='false' preserveAspectRatio='xMidYMid meet' xmlns='http://www.w3.org/2000/svg' fill='currentColor' width='16' height='16' viewBox='0 0 16 16' aria-hidden='true' class='cds--tree-node__icon'%3E%3Cpath d='M8 11L3 6 3.7 5.3 8 9.6 12.3 5.3 13 6z'%3E%3C/path%3E%3C/svg%3E%0A");
132
+ content: '';
133
+ }
134
+
135
+ // hide original icon
136
+ :global(.cds--tree-parent-node__toggle-icon) {
137
+ display: none;
138
+ }
139
+
140
+ // parent tree node open
141
+ :global(.cds--tree-node[aria-expanded='true']) {
142
+ > * > :global(.cds--tree-parent-node__toggle::after) {
143
+ transform: rotate(180deg);
144
+ }
145
+ }
146
+
147
+ // divider styles
148
+ .divider {
149
+ position: relative;
150
+ padding-bottom: spacing.$spacing-05;
151
+ margin-bottom: spacing.$spacing-05;
152
+ }
153
+
154
+ .divider::after {
155
+ content: '';
156
+ position: absolute;
157
+ left: spacing.$spacing-05;
158
+ right: spacing.$spacing-05;
159
+ bottom: 0;
160
+ height: 1px;
161
+ background-color: var(--cds-ui-03, #e0e0e0);
162
+ }
163
+ }
@@ -17,6 +17,10 @@ const useNavigationList = () => {
17
17
  pages {
18
18
  title
19
19
  path
20
+ pages {
21
+ path
22
+ title
23
+ }
20
24
  }
21
25
  }
22
26
  }
@@ -28,12 +32,25 @@ const useNavigationList = () => {
28
32
  `);
29
33
 
30
34
  return [
31
- edges.flatMap(({ node }) =>
32
- node.pages.map((page) => ({
33
- ...page,
34
- category: node.title,
35
- }))
36
- ),
35
+ edges.flatMap(({ node }) => {
36
+ const navArr = [];
37
+ node.pages.forEach((page) => {
38
+ if (page.pages?.length) {
39
+ page.pages.forEach((sublevelPage) => {
40
+ navArr.push({
41
+ ...sublevelPage,
42
+ category: `${node.title}: ${page.title}`,
43
+ });
44
+ });
45
+ } else {
46
+ navArr.push({
47
+ ...page,
48
+ category: node.title,
49
+ });
50
+ }
51
+ });
52
+ return navArr;
53
+ }),
37
54
  pathPrefix,
38
55
  ];
39
56
  };
@@ -75,7 +92,7 @@ const useNavigationItems = ({ tabs, location }) => {
75
92
  : unPrefixedPathname.replace(/\/$/, ''); // removes the last syalash
76
93
 
77
94
  const navIndex = navigationList.findIndex((item) =>
78
- item.path.includes(currentNavigationItem)
95
+ item.path?.includes(currentNavigationItem)
79
96
  );
80
97
 
81
98
  return {
@@ -12,6 +12,10 @@ export function useNavItems() {
12
12
  pages {
13
13
  title
14
14
  path
15
+ pages {
16
+ path
17
+ title
18
+ }
15
19
  }
16
20
  hasDivider
17
21
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Iterates through all nodes in a tree and stops if a returnFunction() condition is met
3
+ * @param {{[key]: any, pages: object[]}[]} nodes tree
4
+ * @returns {void}
5
+ */
6
+ export const dfs = (nodes, returnFunction) => {
7
+ let node;
8
+ const stack = [];
9
+ stack.push(...nodes);
10
+ while (stack.length > 0) {
11
+ node = stack.pop();
12
+ if (returnFunction(node)) {
13
+ return node;
14
+ }
15
+ node.pages?.forEach((item) => {
16
+ stack.push(item);
17
+ });
18
+ }
19
+ };