• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mia-platform / design-system / 13071907942

31 Jan 2025 11:32AM UTC coverage: 68.605% (-29.3%) from 97.875%
13071907942

Pull #817

github

web-flow
Merge fa7bd8bdd into 174906d3a
Pull Request #817: feat(SplitButton): add onOpenChange prop

123 of 256 branches covered (48.05%)

Branch coverage included in aggregate %.

3 of 3 new or added lines in 1 file covered. (100.0%)

56 existing lines in 12 files now uncovered.

290 of 346 relevant lines covered (83.82%)

4.92 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

62.11
/src/components/Dropdown/Dropdown.tsx
1
/**
2
 * Copyright 2024 Mia srl
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *     http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 *
16
 * SPDX-License-Identifier: Apache-2.0
17
 */
18

19
import React, { ReactElement, ReactNode, useCallback, useMemo, useState } from 'react'
2✔
20
import { Dropdown as AntdDropdown } from 'antd'
2✔
21
import classNames from 'classnames'
2✔
22

23
import { AntdMenuClickEvent, AntdMenuItem, AntdMenuItems, Placement, antdSourceMap } from './types'
2✔
24
import { DropdownClickEvent, DropdownItem, DropdownProps, DropdownTrigger, ItemLayout } from './props'
2✔
25
import { Footer, useFooterWithHookedActions } from './components/Footer'
2✔
26
import { Divider } from '../Divider'
2✔
27
import Label from './components/Label'
2✔
28
import styles from './dropdown.module.css'
2✔
29
import { useTheme } from '../../hooks/useTheme'
2✔
30

31
export const defaults = {
2✔
32
  itemLayout: ItemLayout.Horizontal,
33
  triggers: [DropdownTrigger.Click],
34
  initialSelectedItems: [],
35
  menuItemsMaxHeight: 240,
36
  placement: Placement.BottomLeft,
37
}
38

39
export const Dropdown = ({
2✔
40
  autoFocus,
41
  children,
42
  footer,
43
  isDisabled,
44
  itemLayout = defaults.itemLayout,
15✔
45
  items,
46
  menuItemsMaxHeight = defaults.menuItemsMaxHeight,
15✔
47
  onClick,
48
  triggers = defaults.triggers,
15✔
49
  onOpenChange,
50
  getPopupContainer,
51
  initialSelectedItems = defaults.initialSelectedItems,
15✔
52
  multiple,
53
  persistSelection = true,
×
54
  placement = defaults.placement,
×
55
}: DropdownProps): ReactElement => {
56
  const { spacing } = useTheme()
30✔
57

58
  const uniqueOverlayClassName = useMemo(() => `dropdown-overlay-${crypto.randomUUID()}`, [])
30✔
59
  const uniqueDropdownClassName = useMemo(() => `dropdown-${crypto.randomUUID()}`, [])
30✔
60

61
  const itemFinderMemo = useCallback((id: string) => itemFinder(items, id), [items])
30✔
62

63
  const antdItems = useMemo<AntdMenuItems>(() => itemsAdapter(items, itemLayout), [itemLayout, items])
30✔
64
  /* istanbul ignore next */
65
  const innerNode = useMemo(() => (children ? <span>{children}</span> : null), [children])
66

67
  const [selectedItems, setSelectedItems] = useState<string[]>(persistSelection ? initialSelectedItems : [])
30!
68
  const updateSelectedItems = useCallback(
30✔
69
    (itemId: string) => {
70
      if (!persistSelection) {
4✔
71
        return
4✔
72
      }
73

UNCOV
74
      setSelectedItems(prevItems => (multiple ? pushOrRemove(prevItems, itemId) : [itemId]))
×
75
    },
76
    [multiple, persistSelection]
77
  )
78

79
  const onAntdMenuClick = useCallback(
30✔
80
    (antdEvent: AntdMenuClickEvent) => {
81
      const itemClickEvent: DropdownClickEvent = eventAdapter(antdEvent, itemFinderMemo)
4✔
82
      updateSelectedItems(itemClickEvent.id)
4✔
83
      onClick(itemClickEvent)
4✔
84
    },
85
    [itemFinderMemo, onClick, updateSelectedItems]
86
  )
87

88
  /**
89
   * This function is used to forcibly close the dropdown without controlling
90
   * the `open` state via prop.
91
   */
92
  const footerActionHook = useCallback(() => {
30✔
UNCOV
93
    const el = document.querySelector(`.${uniqueDropdownClassName}`)
×
94
    // This branch is not testable since the dropdown always exists when the dropdown is visible
95
    /* istanbul ignore if */
UNCOV
96
    if (!el) {
×
97
      return
98
    }
99
    // FIXME: with hover trigger this does not work and the dropdown will not be closed!
UNCOV
100
    (el as HTMLElement).click()
×
101
  }, [uniqueDropdownClassName])
102
  const hookedFooter = useFooterWithHookedActions({ footer, hook: footerActionHook })
30✔
103

104
  const dropdownRender = useCallback((menu: ReactNode): ReactNode => {
30✔
105
    const clonedMenu = React.cloneElement(menu as ReactElement, { style: { boxShadow: 'none' } })
4✔
106
    const scrollableStyle = { maxHeight: menuItemsMaxHeight, overflow: 'scroll' }
4✔
107
    if (!hookedFooter) {
4✔
108
      return (
4✔
109
        <div className={styles.dropdownRenderWrapper} style={scrollableStyle}>
110
          {clonedMenu}
111
        </div>
112
      )
113
    }
114

UNCOV
115
    return (
×
116
      <div className={styles.dropdownRenderWrapper}>
117
        <div style={scrollableStyle}>{clonedMenu}</div>
118
        <div className={styles.footerDivider}>
119
          <Divider margin={spacing?.margin?.none} />
120
        </div>
121
        <Footer footer={hookedFooter} />
122
      </div>
123
    )
124
  }, [hookedFooter, menuItemsMaxHeight, spacing])
125

126
  const menu = useMemo(() => ({
30✔
127
    items: antdItems,
128
    /* istanbul ignore next */
UNCOV
129
    getPopupContainer: (triggerNode: HTMLElement) => (document.querySelector(`.${uniqueOverlayClassName}`) || triggerNode) as HTMLElement,
×
130
    onClick: onAntdMenuClick,
131
    selectedKeys: selectedItems,
132
  }), [antdItems, onAntdMenuClick, selectedItems, uniqueOverlayClassName])
133

134
  const classes = useMemo(() => classNames(styles.dropdownWrapper, uniqueOverlayClassName), [uniqueOverlayClassName])
30✔
135

136
  const onOpenChangeInternal = useCallback(
30✔
137
    (open: boolean, info: { source: 'trigger' | 'menu' }) => {
138
      if (!onOpenChange) {
8!
UNCOV
139
        return
×
140
      }
141
      onOpenChange(open, { source: antdSourceMap[info.source] })
8✔
142
    },
143
    [onOpenChange]
144
  )
145

146
  return (
30✔
147
    <AntdDropdown
148
      autoFocus={autoFocus}
149
      className={uniqueDropdownClassName}
150
      disabled={isDisabled}
151
      dropdownRender={dropdownRender}
152
      getPopupContainer={getPopupContainer}
153
      menu={menu}
154
      overlayClassName={classes}
155
      placement={placement}
156
      trigger={triggers}
157
      onOpenChange={onOpenChangeInternal}
158
    >
159
      {innerNode}
160
    </AntdDropdown>
161
  )
162
}
163

164
Dropdown.ItemLayout = ItemLayout
2✔
165
Dropdown.Trigger = DropdownTrigger
2✔
166
Dropdown.Placement = Placement
2✔
167

168
function itemsAdapter(items: DropdownItem[], layout: ItemLayout): AntdMenuItems {
169
  return items.map<AntdMenuItem>((item: DropdownItem) => ({
44✔
170
    children: item.children ? itemsAdapter(item.children, layout) : undefined,
22!
171
    danger: item.danger,
172
    key: item.id,
173
    label: <Label item={item} layout={layout} />,
174
  }))
175
}
176

177
function eventAdapter(
178
  event: AntdMenuClickEvent,
179
  finder: (id: string) => DropdownItem | undefined,
180
): DropdownClickEvent {
181
  return {
4✔
182
    id: event.key,
183
    selectedPath: event.keyPath?.reverse(),
184
    domEvent: event.domEvent,
185
    item: finder(event.key),
186
  }
187
}
188

189
function itemFinder(items: DropdownItem[], id: string): DropdownItem | undefined {
190
  for (const item of items) {
4✔
191
    if (item.id === id) {
4✔
192
      return item
4✔
193
    }
194

UNCOV
195
    if (item.children) {
×
UNCOV
196
      const found = itemFinder(item.children, id)
×
UNCOV
197
      if (found) {
×
UNCOV
198
        return found
×
199
      }
200
    }
201
  }
202
}
203

204
function pushOrRemove(prevItems: string[], itemId: string): string[] {
UNCOV
205
  const newItems = [...prevItems]
×
UNCOV
206
  const indexOfItem = newItems.indexOf(itemId)
×
UNCOV
207
  if (indexOfItem < 0) {
×
UNCOV
208
    newItems.push(itemId)
×
UNCOV
209
    return newItems
×
210
  }
211

UNCOV
212
  newItems.splice(indexOfItem, 1)
×
UNCOV
213
  return newItems
×
214
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc