@cypress-design/react-docmenu 0.4.0 → 0.4.1

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.
@@ -2,5 +2,5 @@ $ yarn build:module && yarn build:types
2
2
  $ rollup -c ./rollup.config.mjs
3
3
  
4
4
  ./index.ts → ./dist/index.umd.js, ./dist/index.es.mjs...
5
- created ./dist/index.umd.js, ./dist/index.es.mjs in 11.9s
5
+ created ./dist/index.umd.js, ./dist/index.es.mjs in 13.3s
6
6
  $ tsc --project ./tsconfig.build.json
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # @cypress-design/react-docmenu
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#353](https://github.com/cypress-io/cypress-design/pull/353) [`b382b9e`](https://github.com/cypress-io/cypress-design/commit/b382b9ecc99e3ab6288eee406831199edb661c19) Thanks [@elevatebart](https://github.com/elevatebart)! - feat: Docgen wrapping active links
8
+
9
+ - Updated dependencies [[`b382b9e`](https://github.com/cypress-io/cypress-design/commit/b382b9ecc99e3ab6288eee406831199edb661c19)]:
10
+ - @cypress-design/constants-docmenu@0.1.2
11
+
3
12
  ## 0.4.0
4
13
 
5
14
  ### Minor Changes
package/DocMenu.tsx CHANGED
@@ -1,45 +1,63 @@
1
1
  import * as React from 'react'
2
2
  import { NavGroup, NavItemLink } from '@cypress-design/constants-docmenu'
3
- import { DocLink, LinkComponentType } from './_DocLink'
4
- import { DocGroup } from './_DocGroup'
5
- import clsx from 'clsx'
3
+ import type { LinkComponentType } from './_DocLink'
4
+ import { DocGroupElements } from './_DocGroup'
6
5
 
7
6
  export type NavItem = NavGroup | NavItemLink
8
7
 
9
8
  export interface DocMenuProps extends React.HTMLAttributes<HTMLUListElement> {
10
9
  items: NavItem[]
10
+ activePath?: string
11
11
  collapsible?: boolean
12
12
  LinkComponent?: LinkComponentType
13
13
  }
14
14
 
15
15
  export const DocMenu: React.FC<DocMenuProps> = ({
16
16
  items,
17
+ activePath = '<unknown>',
17
18
  collapsible = true,
18
19
  LinkComponent = 'a',
19
20
  ...rest
20
21
  }) => {
22
+ const [activeTop, setActiveTop] = React.useState(0)
23
+ const [activeHeight, setActiveHeight] = React.useState(36)
24
+ const [showMarker, setShowMarker] = React.useState(false)
25
+
26
+ const container = React.useRef<HTMLDivElement>(null)
27
+
28
+ const setActivePosition = React.useCallback(
29
+ (opts: { top: number; height: number }) => {
30
+ const containerTop = container.current?.getBoundingClientRect().top || 0
31
+ setShowMarker(true)
32
+ setActiveTop(opts.top - containerTop)
33
+ setActiveHeight(opts.height)
34
+ },
35
+ [],
36
+ )
37
+
21
38
  return (
22
- <ul {...rest} className={clsx('list-none p-0', rest.className)}>
23
- {items.map((item, index) =>
24
- 'items' in item ? (
25
- <li key={index} className="relative list-none p-0">
26
- <DocGroup
27
- index={index}
28
- group={item}
29
- collapsible={collapsible}
30
- LinkComponent={LinkComponent}
31
- />
32
- </li>
33
- ) : (
34
- <DocLink
35
- key={index}
36
- item={item}
37
- collapsible={collapsible}
38
- LinkComponent={LinkComponent}
39
- />
40
- ),
41
- )}
42
- </ul>
39
+ <div ref={container} className="relative">
40
+ {showMarker && collapsible ? (
41
+ <div
42
+ className="absolute h-[36px] w-[4px] z-50 rounded-full bg-indigo-500 transition-all duration-300 ml-[6.5px] mt-[4px]"
43
+ style={{
44
+ top: `${activeTop + 4}px`,
45
+ height: `${activeHeight - 8}px`,
46
+ }}
47
+ />
48
+ ) : null}
49
+ <DocGroupElements
50
+ {...rest}
51
+ className={!open ? 'hidden' : undefined}
52
+ items={items}
53
+ activePath={activePath}
54
+ collapsible={collapsible}
55
+ depth={-1}
56
+ onActivePosition={setActivePosition}
57
+ LinkComponent={LinkComponent}
58
+ hideMarker={() => setShowMarker(false)}
59
+ />
60
+ </div>
43
61
  )
44
62
  }
45
63
 
@@ -25,13 +25,117 @@ describe('<DocMenu/>', () => {
25
25
  items={[
26
26
  {
27
27
  label: 'Baaaaaaz',
28
- items: [{ label: 'Bar', items: [{ href: '/foo', label: 'Foo' }] }],
28
+ items: [
29
+ {
30
+ label: 'Bar',
31
+ items: [{ href: '/foo', label: 'Foo' }],
32
+ },
33
+ ],
29
34
  },
30
35
  ]}
31
36
  />,
32
37
  )
33
38
  })
34
39
 
40
+ it(
41
+ 'wraps items correctly',
42
+ { viewportHeight: 950, viewportWidth: 350 },
43
+ () => {
44
+ const Sut = () => {
45
+ const [activeHref, setActiveHref] = React.useState<string | undefined>()
46
+
47
+ return (
48
+ <div className="w-[200px] mt-[60px] absolute">
49
+ <DocMenu
50
+ activePath={activeHref}
51
+ LinkComponent={({ href, className, children }) => (
52
+ <div className={className} onClick={() => setActiveHref(href)}>
53
+ {children}
54
+ </div>
55
+ )}
56
+ items={[
57
+ {
58
+ href: '/fooTop',
59
+ label: 'Foo Top',
60
+ },
61
+ {
62
+ href: '/test',
63
+ label: 'Getting started',
64
+ },
65
+ {
66
+ href: '/faaz',
67
+ label:
68
+ 'lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,',
69
+ },
70
+ {
71
+ label: 'Baaaaaaz',
72
+ items: [
73
+ {
74
+ label: 'Bar',
75
+ items: [
76
+ {
77
+ href: '/foo',
78
+ label: 'Foo',
79
+ },
80
+ {
81
+ href: '/test',
82
+ label: 'Getting started',
83
+ },
84
+ ],
85
+ },
86
+ {
87
+ label: 'Code',
88
+ collapsed: true,
89
+ items: [
90
+ {
91
+ href: '/faa',
92
+ label: 'sasassa',
93
+ },
94
+ {
95
+ href: '/faa1',
96
+ label: 'sasassa2',
97
+ },
98
+ {
99
+ href: '/faa2',
100
+ label: 'sasassa3',
101
+ },
102
+ {
103
+ href: '/faa3',
104
+ label: 'sasassa4',
105
+ },
106
+ {
107
+ href: '/faa4',
108
+ label:
109
+ 'lorem ipsum dolor sit amet consectetur adipisicing elit',
110
+ },
111
+ {
112
+ href: '/faa5',
113
+ label: 'sasassa5',
114
+ },
115
+ {
116
+ href: '/faa6',
117
+ label:
118
+ 'lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos',
119
+ },
120
+ ],
121
+ },
122
+ ],
123
+ },
124
+ ]}
125
+ />
126
+ </div>
127
+ )
128
+ }
129
+ mount(<Sut />)
130
+
131
+ cy.findByText('Bar').click()
132
+ cy.findByText('Code').click()
133
+ cy.findByText(
134
+ 'lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos',
135
+ ).click()
136
+ },
137
+ )
138
+
35
139
  it('renders the custom link components', () => {
36
140
  mount(
37
141
  <DocMenu
@@ -48,12 +152,15 @@ describe('<DocMenu/>', () => {
48
152
  )}
49
153
  />,
50
154
  )
51
- cy.findByText('Bar').click()
155
+
52
156
  cy.findByText('Foo + href: /foo', { selector: 'div' }).should('be.visible')
53
157
  })
54
158
 
55
- function mountStory(items: (NavItemLink | NavGroup)[] = []) {
56
- mount(<DocMenu items={items} />)
159
+ function mountStory(items: (NavItemLink | NavGroup)[] = [], activePath = '') {
160
+ mount(<DocMenu items={items} activePath={activePath} />)
57
161
  }
58
162
  assertions(mountStory)
59
163
  })
164
+
165
+ // pseudo text
166
+ // lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos
package/ReadMe.md CHANGED
@@ -23,22 +23,22 @@ import DocMenu from '@cypress-design/react-docmenu'
23
23
  ```tsx live
24
24
  export default () => (
25
25
  <DocMenu
26
+ activePath="#group-item-3"
26
27
  items={[
27
28
  {
28
29
  label: 'Get Started',
29
- href: '#',
30
+ href: '#get-started',
30
31
  },
31
32
  {
32
33
  label: 'Overview',
33
34
  items: [
34
35
  {
35
36
  label: 'Overview Item 1',
36
- href: '#',
37
+ href: '#overview-item-1',
37
38
  },
38
39
  {
39
40
  label: 'Overview Item 2',
40
- href: '#',
41
- active: true,
41
+ href: '#overview-item-2',
42
42
  },
43
43
  ],
44
44
  },
@@ -47,33 +47,32 @@ export default () => (
47
47
  items: [
48
48
  {
49
49
  label: 'Item 1',
50
- href: '#',
50
+ href: '#item-1',
51
51
  },
52
52
  {
53
53
  label: 'Group',
54
54
  items: [
55
55
  {
56
56
  label: 'Item 1',
57
- href: '#',
57
+ href: '#group-item-1',
58
58
  },
59
59
  {
60
60
  label: 'Item 2',
61
- href: '#',
61
+ href: '#group-item-2',
62
62
  },
63
63
  {
64
64
  label: 'Item 3',
65
- href: '#',
65
+ href: '#group-item-3',
66
66
  },
67
67
  {
68
68
  label: 'Item 4',
69
- href: '#',
69
+ href: '#group-item-4',
70
70
  },
71
71
  ],
72
72
  },
73
73
  {
74
74
  label: 'Item 5',
75
75
  href: '#',
76
- active: true,
77
76
  },
78
77
  ],
79
78
  },
package/_DocGroup.tsx CHANGED
@@ -1,140 +1,262 @@
1
1
  import * as React from 'react'
2
2
  import { IconChevronDownSmall } from '@cypress-design/react-icon'
3
3
  import clsx from 'clsx'
4
- import { NavGroup, classes } from '@cypress-design/constants-docmenu'
5
- import { DocLink, LinkComponentType } from './_DocLink'
4
+ import {
5
+ type NavGroup,
6
+ type NavItemLink,
7
+ classes,
8
+ } from '@cypress-design/constants-docmenu'
9
+ import {
10
+ DocLink,
11
+ type DocLinkForward,
12
+ type LinkComponentType,
13
+ } from './_DocLink'
6
14
 
7
15
  export interface DocGroupProps {
8
- index: number
9
16
  group: NavGroup
17
+ activePath: string
10
18
  collapsible: boolean
19
+ onActivePosition: (opts: { top: number; height: number }) => void
20
+ updateMarkerPosition?: () => void
11
21
  depth?: number
12
- setHeight?: (height: number) => void
13
22
  LinkComponent?: LinkComponentType
23
+ hideMarker: () => void
14
24
  }
15
25
 
16
- export const DocGroup: React.FC<DocGroupProps> = ({
17
- group,
18
- collapsible,
19
- depth = 0,
20
- setHeight,
21
- index,
22
- LinkComponent = 'a',
23
- }) => {
24
- const [open, setOpen] = React.useState(depth === 0)
25
- const [itemsHeights, setItemsHeights] = React.useState<number[]>(
26
- Array(group.items.length).fill(1),
27
- )
28
-
29
- React.useEffect(() => {
30
- // if this group contains other groups, ignore this effect
31
- // it will be triggered by a leaf and trickle up
32
- if (group.items.some((item) => 'items' in item)) {
33
- return
34
- }
26
+ export interface DocGroupForward {
27
+ reTriggerSetActiveGroup: () => void
28
+ }
35
29
 
36
- const newHeight = open ? itemsHeights.reduce((a, b) => a + b, 1) : 1
30
+ export const DocGroup = React.forwardRef<DocGroupForward, DocGroupProps>(
31
+ (
32
+ {
33
+ group,
34
+ activePath,
35
+ collapsible,
36
+ depth = 0,
37
+ onActivePosition,
38
+ updateMarkerPosition,
39
+ LinkComponent = 'a',
40
+ hideMarker,
41
+ },
42
+ ref,
43
+ ) => {
44
+ const [open, setOpen] = React.useState(group.collapsed !== true)
45
+ const $groupElements = React.useRef<DocGroupElementsForward>(null)
37
46
 
38
- setHeight?.(newHeight)
39
- }, [itemsHeights, open, setHeight, group.items, group.label])
47
+ function toggleMenu(open: boolean) {
48
+ if (!collapsible) return
49
+ setOpen(open)
50
+ }
40
51
 
41
- const [activeTop, setActiveTop] = React.useState(0)
52
+ const hasActiveItemRecursively = React.useCallback(
53
+ (items = group.items): boolean => {
54
+ return items.some((item) => {
55
+ if ('items' in item) {
56
+ return hasActiveItemRecursively(item.items)
57
+ }
58
+ return item.href === activePath
59
+ })
60
+ },
61
+ [group.items, activePath],
62
+ )
42
63
 
43
- React.useEffect(() => {
44
- const activeItem = group.items.findIndex(
45
- (item) => 'href' in item && item.active,
64
+ const Head = collapsible ? 'button' : group.href ? 'a' : 'div'
65
+
66
+ const setActivePosition = React.useCallback(
67
+ ({ top, height }: { top: number; height: number }) => {
68
+ if (open) {
69
+ onActivePosition({ top, height })
70
+ }
71
+ },
72
+ [onActivePosition, open],
46
73
  )
47
- if (activeItem >= 0) {
48
- setActiveTop(
49
- itemsHeights.slice(0, activeItem).reduce((a, b) => a + b, 1) * 44,
50
- )
74
+
75
+ function reTriggerSetActiveGroup() {
76
+ $groupElements.current?.reTriggerSetActiveGroup()
51
77
  }
52
- }, [itemsHeights, group.items])
53
-
54
- function toggleMenu(open: boolean) {
55
- if (!collapsible) return
56
- setOpen(open)
57
- }
58
-
59
- const onSetHeightCallback = React.useCallback(
60
- (height: number) => {
61
- setItemsHeights((prev) => {
62
- const newHeights = [...prev]
63
- newHeights[index] = height
64
- return newHeights
65
- })
66
- },
67
- [setItemsHeights, index],
68
- )
69
-
70
- const Head = collapsible ? 'button' : group.href ? 'a' : 'div'
71
-
72
- return (
73
- <>
74
- <Head
75
- onClick={() => toggleMenu(!open)}
76
- href={group.href}
77
- className={clsx(classes.button, {
78
- 'text-indigo-500': group.active,
79
- [classes.topButton]: depth === 0,
80
- [classes.leafButton]: depth,
81
- })}
82
- >
83
- {collapsible ? (
84
- <IconChevronDownSmall
85
- stroke-color="gray-400"
86
- size={depth ? '8' : '16'}
87
- className={clsx('absolute transform transition-transform left-0', {
88
- 'rotate-0': open,
89
- '-rotate-90': !open,
90
- 'ml-[16px]': depth,
91
- })}
92
- />
93
- ) : null}
94
- {group.label}
95
- </Head>
96
- {collapsible &&
97
- depth >= 0 &&
98
- open &&
99
- group.items.some((item) => 'href' in item && item.active) ? (
100
- <div
101
- className="absolute h-[36px] w-[4px] z-10 rounded-full bg-indigo-500 transition-all duration-300 ml-[6px] mt-[4px]"
102
- style={{
103
- top: `${activeTop}px`,
104
- left: `${depth === 0 ? 0.5 : -(depth * 8) - 0.5}px`,
105
- }}
78
+
79
+ React.useEffect(() => {
80
+ if (hasActiveItemRecursively()) {
81
+ if (open) {
82
+ reTriggerSetActiveGroup()
83
+ } else {
84
+ hideMarker()
85
+ }
86
+ } else {
87
+ updateMarkerPosition?.()
88
+ }
89
+ }, [open, updateMarkerPosition, hideMarker, hasActiveItemRecursively])
90
+
91
+ React.useImperativeHandle(ref, () => ({
92
+ reTriggerSetActiveGroup,
93
+ }))
94
+
95
+ const onUpdateMarkerPosition = React.useCallback(() => {
96
+ if (hasActiveItemRecursively()) {
97
+ reTriggerSetActiveGroup()
98
+ } else {
99
+ updateMarkerPosition?.()
100
+ }
101
+ }, [hasActiveItemRecursively, updateMarkerPosition])
102
+
103
+ return (
104
+ <>
105
+ <Head
106
+ onClick={() => toggleMenu(!open)}
107
+ href={group.href}
108
+ className={clsx(classes.button, {
109
+ [classes.topButton]: depth === 0,
110
+ [classes.leafButton]: depth,
111
+ 'text-indigo-500': activePath === group.href,
112
+ })}
113
+ >
114
+ {collapsible ? (
115
+ <IconChevronDownSmall
116
+ stroke-color="gray-400"
117
+ size={depth ? '8' : '16'}
118
+ className={clsx(
119
+ 'absolute transform transition-transform left-0',
120
+ {
121
+ 'rotate-0': open,
122
+ '-rotate-90': !open,
123
+ 'ml-[16px]': depth,
124
+ },
125
+ )}
126
+ />
127
+ ) : null}
128
+ {group.label}
129
+ </Head>
130
+ <DocGroupElements
131
+ ref={$groupElements}
132
+ className={!open ? 'hidden' : undefined}
133
+ items={group.items}
134
+ activePath={activePath}
135
+ collapsible={collapsible}
136
+ depth={depth}
137
+ onActivePosition={setActivePosition}
138
+ updateMarkerPosition={onUpdateMarkerPosition}
139
+ LinkComponent={LinkComponent}
140
+ hideMarker={hideMarker}
106
141
  />
107
- ) : null}
142
+ </>
143
+ )
144
+ },
145
+ )
146
+
147
+ export interface DocGroupElementsProps
148
+ extends React.HTMLAttributes<HTMLUListElement> {
149
+ items: (NavGroup | NavItemLink)[]
150
+ activePath: string
151
+ collapsible: boolean
152
+ onActivePosition: (opts: { top: number; height: number }) => void
153
+ updateMarkerPosition?: () => void
154
+ depth: number
155
+ LinkComponent: LinkComponentType
156
+ hideMarker: () => void
157
+ className: string | undefined
158
+ }
159
+
160
+ export interface DocGroupElementsForward {
161
+ reTriggerSetActiveGroup: () => void
162
+ }
163
+
164
+ export const DocGroupElements = React.forwardRef<
165
+ DocGroupElementsForward,
166
+ DocGroupElementsProps
167
+ >(
168
+ (
169
+ {
170
+ items,
171
+ activePath,
172
+ collapsible,
173
+ depth = 0,
174
+ onActivePosition,
175
+ updateMarkerPosition,
176
+ LinkComponent,
177
+ hideMarker,
178
+ className,
179
+ ...rest
180
+ },
181
+ ref,
182
+ ) => {
183
+ const $groups = React.useRef<DocGroupForward[]>([])
184
+ const $items = React.useRef<DocLinkForward[]>([])
185
+
186
+ const hasActiveItemRecursively = React.useCallback(
187
+ (localItems = items): boolean => {
188
+ return localItems.some((item) => {
189
+ if ('items' in item) {
190
+ return hasActiveItemRecursively(item.items)
191
+ }
192
+ return item.href === activePath
193
+ })
194
+ },
195
+ [items, activePath],
196
+ )
197
+
198
+ function reTriggerSetActiveGroup() {
199
+ $groups.current.forEach((group) => {
200
+ group?.reTriggerSetActiveGroup()
201
+ })
202
+ $items.current.forEach((item) => {
203
+ item?.setActiveMarkerPosition()
204
+ })
205
+ }
206
+
207
+ React.useImperativeHandle(ref, () => ({
208
+ reTriggerSetActiveGroup,
209
+ }))
210
+
211
+ const onUpdateMarkerPosition = React.useCallback(() => {
212
+ if (hasActiveItemRecursively()) {
213
+ reTriggerSetActiveGroup()
214
+ } else {
215
+ updateMarkerPosition?.()
216
+ }
217
+ }, [hasActiveItemRecursively, updateMarkerPosition])
218
+
219
+ return (
108
220
  <ul
109
- className={clsx('ml-[8px] list-none p-0', {
221
+ {...rest}
222
+ className={clsx('list-none p-0', className, {
110
223
  'border-l border-gray-100': depth === 0 && collapsible,
111
- hidden: !open,
224
+ 'ml-[8px]': depth >= 0,
112
225
  })}
113
226
  >
114
- {group.items.map((item, index) =>
227
+ {items.map((item, index) =>
115
228
  'items' in item ? (
116
229
  <li key={index} className="relative list-none p-0">
117
230
  <DocGroup
231
+ ref={(el: DocGroupForward) => {
232
+ $groups.current[index] = el
233
+ }}
118
234
  group={item}
235
+ activePath={activePath}
119
236
  depth={depth + 1}
120
- setHeight={onSetHeightCallback}
121
- index={index}
122
237
  collapsible={collapsible}
123
238
  LinkComponent={LinkComponent}
239
+ hideMarker={hideMarker}
240
+ onActivePosition={onActivePosition}
241
+ updateMarkerPosition={onUpdateMarkerPosition}
124
242
  />
125
243
  </li>
126
244
  ) : (
127
245
  <DocLink
246
+ ref={(el: DocLinkForward) => {
247
+ $items.current[index] = el
248
+ }}
128
249
  key={index}
129
250
  item={item}
251
+ active={item.href === activePath}
130
252
  collapsible={collapsible}
131
253
  depth={depth}
132
- onActive={(top) => setActiveTop(top)}
254
+ onActive={onActivePosition}
133
255
  LinkComponent={LinkComponent}
134
256
  />
135
257
  ),
136
258
  )}
137
259
  </ul>
138
- </>
139
- )
140
- }
260
+ )
261
+ },
262
+ )