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

mia-platform / design-system / 18131535675

30 Sep 2025 01:27PM UTC coverage: 97.536%. First build
18131535675

Pull #1071

github

web-flow
Merge c147df6e5 into 0715be051
Pull Request #1071: add dropdown header; reset search on dropdown close

1141 of 1195 branches covered (95.48%)

Branch coverage included in aggregate %.

6 of 9 new or added lines in 1 file covered. (66.67%)

1788 of 1808 relevant lines covered (98.89%)

71.58 hits per line

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

92.98
/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 { Input as AntInput, Dropdown as AntdDropdown } from 'antd'
20✔
20
import React, { ChangeEvent, ReactElement, ReactNode, useCallback, useMemo, useState } from 'react'
20✔
21
import { PiMagnifyingGlass } from 'react-icons/pi'
20✔
22
import classNames from 'classnames'
20✔
23

24
import { AntdMenuClickEvent, AntdMenuItem, AntdMenuItems, Placement, antdSourceMap } from './types'
20✔
25
import { DropdownClickEvent, DropdownItem, DropdownProps, DropdownTrigger, ItemLayout } from './props'
20✔
26
import { Footer, useFooterWithHookedActions } from './components/Footer'
20✔
27
import { BaseInput } from '../BaseInput'
20✔
28
import { Divider } from '../Divider'
20✔
29
import { EmptyState } from './components/EmptyState'
20✔
30
import { ErrorState } from './components/ErrorState'
20✔
31
import { Icon } from '../Icon'
20✔
32
import Label from './components/Label'
20✔
33
import { Loader } from './components/Loader'
20✔
34
import styles from './dropdown.module.css'
20✔
35
import { useTheme } from '../../hooks/useTheme'
20✔
36

37
const ICON_SIZE = 12 as never
20✔
38

39
export const defaults = {
20✔
40
  itemLayout: ItemLayout.Horizontal,
41
  triggers: [DropdownTrigger.Click],
42
  initialSelectedItems: [],
43
  menuItemsMaxHeight: 240,
44
  placement: Placement.BottomLeft,
45
}
46

47
export const Dropdown = ({
20✔
48
  autoFocus,
49
  children,
50
  header,
51
  footer,
52
  isDisabled,
53
  itemLayout = defaults.itemLayout,
88✔
54
  items,
55
  menuItemsMaxHeight = defaults.menuItemsMaxHeight,
88✔
56
  onClick,
57
  triggers = defaults.triggers,
87✔
58
  onOpenChange,
59
  getPopupContainer,
60
  initialSelectedItems = defaults.initialSelectedItems,
81✔
61
  multiple,
62
  persistSelection = true,
70✔
63
  placement = defaults.placement,
73✔
64
  isSearchable = false,
44✔
65
  onSearch,
66
  searchPlaceholder = 'Search...',
87✔
67
  isLoading = false,
87✔
68
  hasError = false,
87✔
69
  errorMessage = 'An error occurred',
88✔
70
  onRetry,
71
}: DropdownProps): ReactElement => {
72
  const { spacing, palette } = useTheme()
176✔
73

74
  const uniqueOverlayClassName = useMemo(() => `dropdown-overlay-${crypto.randomUUID()}`, [])
176✔
75
  const uniqueDropdownClassName = useMemo(() => `dropdown-${crypto.randomUUID()}`, [])
176✔
76

77
  const itemFinderMemo = useCallback((id: string) => itemFinder(items, id), [items])
176✔
78

79
  /* istanbul ignore next */
80
  const innerNode = useMemo(() => (children ? <span>{children}</span> : null), [children])
81

82
  const [selectedItems, setSelectedItems] = useState<string[]>(persistSelection ? initialSelectedItems : [])
176✔
83
  const updateSelectedItems = useCallback((itemId: string) => {
176✔
84
    if (!persistSelection) {
24✔
85
      return
8✔
86
    }
87

88
    setSelectedItems(prevItems => (multiple ? pushOrRemove(prevItems, itemId) : [itemId]))
16✔
89
  }, [multiple, persistSelection])
90

91
  const onAntdMenuClick = useCallback(
176✔
92
    (antdEvent: AntdMenuClickEvent) => {
93
      const itemClickEvent: DropdownClickEvent = eventAdapter(antdEvent, itemFinderMemo)
24✔
94
      updateSelectedItems(itemClickEvent.id)
24✔
95
      onClick(itemClickEvent)
24✔
96
    },
97
    [itemFinderMemo, onClick, updateSelectedItems]
98
  )
99

100
  const [searchTerm, setSearchTerm] = useState('')
176✔
101

102
  const itemsToRender = useMemo(() => {
176✔
103
    if (onSearch || !searchTerm) {
152✔
104
      return items
120✔
105
    }
106

107
    const lower = searchTerm.toLowerCase()
32✔
108
    const filterRecursively = (list: DropdownItem[]): DropdownItem[] => {
32✔
109
      return list
32✔
110
        .map((item) => ({
76✔
111
          ...item,
112
          children: item.children
38!
113
            ? filterRecursively(item.children)
114
            : undefined,
115
        }))
116
        .filter((item) => (
117
          (typeof item.label !== 'string' && typeof item.label !== 'number')
76!
118
            || item.label?.toString().toLowerCase()
119
              .includes(lower)
120
            || (item.children && item.children.length > 0)
121
        ))
122
    }
123

124
    const filteredItems = filterRecursively(items)
32✔
125
    return filteredItems
32✔
126
  }, [items, onSearch, searchTerm])
127

128
  const antdItems = useMemo<AntdMenuItems>(() => itemsAdapter(itemsToRender, itemLayout), [itemsToRender, itemLayout])
176✔
129

130
  /**
131
   * This function is used to forcibly close the dropdown without controlling
132
   * the `open` state via prop.
133
   */
134
  const footerActionHook = useCallback(() => {
176✔
135
    const el = document.querySelector(`.${uniqueDropdownClassName}`)
2✔
136
    // This branch is not testable since the dropdown always exists when the dropdown is visible
137
    /* istanbul ignore if */
138
    if (!el) {
2✔
139
      return
140
    }
141
    // FIXME: with hover trigger this does not work and the dropdown will not be closed!
142
    (el as HTMLElement).click()
2✔
143
  }, [uniqueDropdownClassName])
144
  const hookedFooter = useFooterWithHookedActions({ footer, hook: footerActionHook })
176✔
145

146
  const handleSearchInputChange = useCallback((ev: ChangeEvent<HTMLInputElement> | undefined) => {
176✔
147
    const value = ev?.target?.value || ''
66✔
148
    setSearchTerm(value)
66✔
149
    onSearch?.(value)
66✔
150
  }, [onSearch])
151

152
  const headerComponent = useMemo(
176✔
153
    () => (
154
      <div style={{ padding: spacing.gap.sm, paddingBottom: spacing.gap.xs }}>
152✔
155
        {header?.top}
156
        {isSearchable && (
120✔
157
          <BaseInput
158
            allowClear
159
            component={AntInput}
160
            isFullWidth
161
            placeholder={searchPlaceholder}
162
            suffix={
163
              <Icon
164
                color={palette.action.secondary.contrastText}
165
                component={PiMagnifyingGlass}
166
                size={ICON_SIZE}
167
              />
168
            }
169
            value={searchTerm}
170
            onChange={handleSearchInputChange}
171
          />
172
        )}
173
        {header?.bottom}
174
      </div>
175
    ),
176
    [
177
      handleSearchInputChange,
178
      header?.bottom,
179
      header?.top,
180
      isSearchable,
181
      palette.action.secondary.contrastText,
182
      searchPlaceholder,
183
      searchTerm,
184
      spacing.gap.sm,
185
      spacing.gap.xs,
186
    ]
187
  )
188

189
  const dropdownRender = useCallback(
176✔
190
    (menu: ReactNode): ReactNode => {
191
      const clonedMenu = React.cloneElement(menu as ReactElement, {
144✔
192
        style: { boxShadow: 'none' },
193
      })
194
      const scrollableStyle = {
144✔
195
        maxHeight: menuItemsMaxHeight,
196
        overflow: 'auto',
197
        borderRadius: 'inherit',
198
      }
199

200
      let dropdownBody = clonedMenu
144✔
201
      if (isLoading) {
144!
202
        dropdownBody = (
×
203
          <div style={{ padding: spacing.padding.sm }}>
204
            <Loader />
205
          </div>
206
        )
207
      } else if (hasError) {
144✔
208
        dropdownBody = (
2✔
209
          <ErrorState
210
            message={errorMessage}
211
            onRetry={() => onRetry?.(searchTerm)}
2✔
212
          />
213
        )
214
      } else if (itemsToRender.length === 0) {
142✔
215
        dropdownBody = <EmptyState />
24✔
216
      }
217

218
      if (!hookedFooter) {
144✔
219
        return (
140✔
220
          <div className={styles.dropdownRenderWrapper}>
221
            {headerComponent}
222
            <div style={scrollableStyle}>{dropdownBody}</div>
223
          </div>
224
        )
225
      }
226

227
      return (
4✔
228
        <div className={styles.dropdownRenderWrapper}>
229
          {headerComponent}
230
          <div style={scrollableStyle}>{dropdownBody}</div>
231
          <div className={styles.footerDivider}>
232
            <Divider margin={spacing?.margin?.none} />
233
          </div>
234
          <Footer footer={hookedFooter} />
235
        </div>
236
      )
237
    },
238
    [
239
      errorMessage,
240
      hasError,
241
      headerComponent,
242
      hookedFooter,
243
      isLoading,
244
      itemsToRender.length,
245
      menuItemsMaxHeight,
246
      onRetry,
247
      searchTerm,
248
      spacing?.margin?.none,
249
      spacing.padding.sm,
250
    ]
251
  )
252

253
  const menu = useMemo(() => ({
176✔
254
    items: antdItems,
255
    /* istanbul ignore next */
256
    getPopupContainer: (triggerNode: HTMLElement) => (document.querySelector(`.${uniqueOverlayClassName}`) || triggerNode) as HTMLElement,
92!
257
    onClick: onAntdMenuClick,
258
    selectedKeys: selectedItems,
259
  }), [antdItems, onAntdMenuClick, selectedItems, uniqueOverlayClassName])
260

261
  const classes = useMemo(() => classNames(styles.dropdownWrapper, uniqueOverlayClassName), [uniqueOverlayClassName])
176✔
262

263
  const onOpenChangeInternal = useCallback(
176✔
264
    (open: boolean, info: { source: 'trigger' | 'menu' }) => {
265
      if (!open && isSearchable && searchTerm) {
104!
NEW
266
        setSearchTerm('')
×
NEW
267
        if (onSearch) {
×
NEW
268
          onSearch('')
×
269
        }
270
      }
271

272
      if (!onOpenChange) {
104✔
273
        return
90✔
274
      }
275
      onOpenChange(open, { source: antdSourceMap[info.source] })
14✔
276
    },
277
    [isSearchable, onOpenChange, onSearch, searchTerm]
278
  )
279

280
  return (
176✔
281
    <AntdDropdown
282
      autoFocus={autoFocus}
283
      className={uniqueDropdownClassName}
284
      disabled={isDisabled}
285
      dropdownRender={dropdownRender}
286
      getPopupContainer={getPopupContainer}
287
      menu={menu}
288
      overlayClassName={classes}
289
      placement={placement}
290
      trigger={triggers}
291
      onOpenChange={onOpenChangeInternal}
292
    >
293
      {innerNode}
294
    </AntdDropdown>
295
  )
296
}
297

298
Dropdown.ItemLayout = ItemLayout
20✔
299
Dropdown.Trigger = DropdownTrigger
20✔
300
Dropdown.Placement = Placement
20✔
301
Dropdown.Loader = Loader
20✔
302
Dropdown.ErrorState = ErrorState
20✔
303
Dropdown.EmptyState = EmptyState
20✔
304

305
function itemsAdapter(items: DropdownItem[], layout: ItemLayout): AntdMenuItems {
306
  return items.map<AntdMenuItem>((item: DropdownItem) => ({
308✔
307
    children: item.children ? itemsAdapter(item.children, layout) : undefined,
154✔
308
    danger: item.danger,
309
    key: item.id,
310
    label: <Label item={item} layout={layout} />,
311
    disabled: item.disabled,
312
  }))
313
}
314

315
function eventAdapter(
316
  event: AntdMenuClickEvent,
317
  finder: (id: string) => DropdownItem | undefined,
318
): DropdownClickEvent {
319
  return {
24✔
320
    id: event.key,
321
    selectedPath: event.keyPath?.reverse(),
322
    domEvent: event.domEvent,
323
    item: finder(event.key),
324
  }
325
}
326

327
function itemFinder(items: DropdownItem[], id: string): DropdownItem | undefined {
328
  for (const item of items) {
30✔
329
    if (item.id === id) {
40✔
330
      return item
24✔
331
    }
332

333
    if (item.children) {
16✔
334
      const found = itemFinder(item.children, id)
6✔
335
      if (found) {
6✔
336
        return found
6✔
337
      }
338
    }
339
  }
340
}
341

342
function pushOrRemove(prevItems: string[], itemId: string): string[] {
343
  const newItems = [...prevItems]
4✔
344
  const indexOfItem = newItems.indexOf(itemId)
4✔
345
  if (indexOfItem < 0) {
4✔
346
    newItems.push(itemId)
2✔
347
    return newItems
2✔
348
  }
349

350
  newItems.splice(indexOfItem, 1)
2✔
351
  return newItems
2✔
352
}
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