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

mia-platform / design-system / 18490863103

14 Oct 2025 08:46AM UTC coverage: 97.69% (-0.08%) from 97.77%
18490863103

Pull #1086

github

web-flow
Merge 642bc12c9 into 9f171e207
Pull Request #1086: Feat(dropdown): added infinite scrolling functionality

1176 of 1229 branches covered (95.69%)

Branch coverage included in aggregate %.

45 of 46 new or added lines in 1 file covered. (97.83%)

1 existing line in 1 file now uncovered.

1826 of 1844 relevant lines covered (99.02%)

77.32 hits per line

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

96.27
/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, Skeleton } from 'antd'
20✔
20
import React, { ChangeEvent, ReactElement, ReactNode, useCallback, useEffect, useMemo, useRef, 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,
182✔
54
  items,
55
  menuItemsMaxHeight = defaults.menuItemsMaxHeight,
172✔
56
  onClick,
57
  triggers = defaults.triggers,
180✔
58
  onOpenChange,
59
  getPopupContainer,
60
  initialSelectedItems = defaults.initialSelectedItems,
171✔
61
  multiple,
62
  persistSelection = true,
150✔
63
  placement = defaults.placement,
156✔
64
  isSearchable = false,
88✔
65
  onSearch,
66
  searchPlaceholder = 'Search...',
180✔
67
  isLoading = false,
180✔
68
  hasError = false,
180✔
69
  errorMessage = 'An error occurred',
182✔
70
  onRetry,
71
  isInfiniteScrollEnabled,
72
  onScrollEndReached,
73
  isLoadingIncrementalItems = false,
182✔
74
  incrementalItems,
75
  scrollThreshold = 32,
176✔
76
}: DropdownProps): ReactElement => {
77
  const { spacing, palette } = useTheme()
364✔
78

79
  const [searchTerm, setSearchTerm] = useState('')
364✔
80
  const [itemsToRender, setItemsToRender] = useState<DropdownItem[]>([])
364✔
81
  const [selectedItems, setSelectedItems] = useState<string[]>(persistSelection ? initialSelectedItems : [])
364✔
82
  const [isScrollable, setIsScrollable] = useState(false)
364✔
83

84
  const prevScrollTopRef = useRef<number>(0)
364✔
85
  const scrollEndWasReached = useRef<boolean>(false)
364✔
86

87
  const uniqueOverlayClassName = useMemo(() => `dropdown-overlay-${crypto.randomUUID()}`, [])
364✔
88
  const uniqueDropdownClassName = useMemo(() => `dropdown-${crypto.randomUUID()}`, [])
364✔
89

90
  /* istanbul ignore next */
91
  const innerNode = useMemo(() => (children ? <span>{children}</span> : null), [children])
92

93
  const itemFinderMemo = useCallback((id: string) => itemFinder(items, id), [items])
364✔
94

95
  const updateSelectedItems = useCallback((itemId: string) => {
364✔
96
    if (!persistSelection) {
26✔
97
      return
8✔
98
    }
99

100
    setSelectedItems(prevItems => (multiple ? pushOrRemove(prevItems, itemId) : [itemId]))
18✔
101
  }, [multiple, persistSelection])
102

103
  const onAntdMenuClick = useCallback(
364✔
104
    (antdEvent: AntdMenuClickEvent) => {
105
      const itemClickEvent: DropdownClickEvent = eventAdapter(antdEvent, itemFinderMemo)
26✔
106
      updateSelectedItems(itemClickEvent.id)
26✔
107
      onClick(itemClickEvent)
26✔
108
      if (isSearchable && searchTerm) {
26✔
109
        setSearchTerm('')
2✔
110
      }
111
    },
112
    [isSearchable, itemFinderMemo, onClick, searchTerm, updateSelectedItems]
113
  )
114

115
  useEffect(() => {
364✔
116
    if (Boolean(onSearch) || isInfiniteScrollEnabled) {
184✔
117
      return
48✔
118
    }
119

120
    if (!searchTerm) {
136✔
121
      setItemsToRender(items)
92✔
122
      return
92✔
123
    }
124

125
    const lower = searchTerm.toLowerCase()
44✔
126
    const filterRecursively = (list: DropdownItem[]): DropdownItem[] => {
44✔
127
      return list
44✔
128
        .map((item) => ({
112✔
129
          ...item,
130
          children: item.children
56!
131
            ? filterRecursively(item.children)
132
            : undefined,
133
        }))
134
        .filter(
135
          (item) =>
136
            (typeof item.label !== 'string'
112!
137
              && typeof item.label !== 'number')
138
            || item.label?.toString().toLowerCase()
139
              .includes(lower)
140
            || (item.children && item.children.length > 0)
141
        )
142
    }
143

144
    const filteredItems = filterRecursively(items)
44✔
145
    setItemsToRender(filteredItems)
44✔
146
  }, [items, onSearch, searchTerm, isInfiniteScrollEnabled])
147

148
  useEffect(() => {
364✔
149
    if (onSearch || isInfiniteScrollEnabled) {
102✔
150
      setItemsToRender(items)
16✔
151
    }
152
  }, [isInfiniteScrollEnabled, items, onSearch])
153

154
  useEffect(() => {
364✔
155
    if (isInfiniteScrollEnabled && incrementalItems && incrementalItems.length > 0) {
102✔
156
      setItemsToRender(currItems => [...currItems, ...incrementalItems])
2✔
157
    }
158
  }, [incrementalItems, isInfiniteScrollEnabled])
159

160
  const antdItems = useMemo<AntdMenuItems>(() => itemsAdapter(itemsToRender, itemLayout), [itemsToRender, itemLayout])
364✔
161

162
  /**
163
   * This function is used to forcibly close the dropdown without controlling
164
   * the `open` state via prop.
165
   */
166
  const footerActionHook = useCallback(() => {
364✔
167
    const el = document.querySelector(`.${uniqueDropdownClassName}`)
2✔
168
    // This branch is not testable since the dropdown always exists when the dropdown is visible
169
    /* istanbul ignore if */
170
    if (!el) {
2✔
171
      return
172
    }
173
    // FIXME: with hover trigger this does not work and the dropdown will not be closed!
174
    (el as HTMLElement).click()
2✔
175
  }, [uniqueDropdownClassName])
176
  const hookedFooter = useFooterWithHookedActions({ footer, hook: footerActionHook })
364✔
177

178
  const handleSearchInputChange = useCallback((ev: ChangeEvent<HTMLInputElement> | undefined) => {
364✔
179
    const value = ev?.target?.value || ''
78✔
180
    setSearchTerm(value)
78✔
181
    onSearch?.(value)
78✔
182
  }, [onSearch])
183

184
  const headerComponent = useMemo(
364✔
185
    () => (
186
      <>
184✔
187
        {header?.top}
188
        {isSearchable && (
146✔
189
          <div style={{ padding: spacing.gap.sm, paddingBottom: spacing.gap.xs }}>
190
            <BaseInput
191
              allowClear
192
              component={AntInput}
193
              isFullWidth
194
              placeholder={searchPlaceholder}
195
              suffix={
196
                <Icon
197
                  color={palette.action.secondary.contrastText}
198
                  component={PiMagnifyingGlass}
199
                  size={ICON_SIZE}
200
                />
201
              }
202
              value={searchTerm}
203
              onChange={handleSearchInputChange}
204
            />
205
          </div>
206
        )}
207
        {header?.bottom}
208
      </>
209
    ),
210
    [
211
      handleSearchInputChange,
212
      header?.bottom,
213
      header?.top,
214
      isSearchable,
215
      palette.action.secondary.contrastText,
216
      searchPlaceholder,
217
      searchTerm,
218
      spacing.gap.sm,
219
      spacing.gap.xs,
220
    ]
221
  )
222

223
  useEffect(() => {
364✔
224
    scrollEndWasReached.current = false
102✔
225
  }, [incrementalItems])
226

227
  const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
364✔
228
    if (!onScrollEndReached || !isInfiniteScrollEnabled) { return }
14✔
229

230
    const { scrollTop, scrollHeight, clientHeight } = event.currentTarget
12✔
231

232
    const distanceFromBottom = scrollHeight - scrollTop - clientHeight
12✔
233
    const isScrollingDown = scrollTop > prevScrollTopRef.current
12✔
234

235
    prevScrollTopRef.current = scrollTop
12✔
236

237
    if (isScrollingDown && distanceFromBottom <= scrollThreshold) {
12✔
238
      if (!scrollEndWasReached.current) {
8✔
239
        scrollEndWasReached.current = true
6✔
240
        onScrollEndReached()
6✔
241
      }
242
    }
243
  }, [isInfiniteScrollEnabled, onScrollEndReached, scrollThreshold])
244

245
  const scrollContainerRef = useCallback((node: HTMLDivElement | null) => {
364✔
246
    if (node) {
164✔
247
      const checkScrollable = (): void => {
82✔
248
        const hasScroll = node.scrollHeight > node.clientHeight
102✔
249
        setIsScrollable(hasScroll)
102✔
250
      }
251

252
      checkScrollable()
82✔
253

254
      const observer = new MutationObserver(checkScrollable)
82✔
255
      observer.observe(node, {
82✔
256
        childList: true,
257
        subtree: true,
258
        attributes: false,
259
      })
260

261
      return () => observer.disconnect()
82✔
262
    }
263
  }, [])
264

265
  const dropdownRender = useCallback(
364✔
266
    (menu: ReactNode): ReactNode => {
267
      const clonedMenu = React.cloneElement(menu as ReactElement, {
222✔
268
        style: { boxShadow: 'none' },
269
      })
270
      const scrollableStyle = {
222✔
271
        maxHeight: menuItemsMaxHeight,
272
        overflow: 'auto',
273
        borderRadius: 'inherit',
274
      }
275

276
      let dropdownBody = clonedMenu
222✔
277
      if (isLoading) {
222!
278
        dropdownBody = (
×
279
          <div style={{ padding: spacing.padding.sm }}>
280
            <Loader />
281
          </div>
282
        )
283
      } else if (hasError) {
222✔
284
        dropdownBody = (
2✔
285
          <ErrorState
286
            message={errorMessage}
287
            onRetry={() => onRetry?.(searchTerm)}
2✔
288
          />
289
        )
290
      } else if (itemsToRender.length === 0) {
220✔
291
        dropdownBody = <EmptyState />
44✔
292
      } else if (isInfiniteScrollEnabled && isScrollable) {
176!
NEW
UNCOV
293
        dropdownBody = (
×
294
          <>
295
            {clonedMenu}
296
            <div style={{ height: '32px', padding: '4px 8px 16px 8px' }}>
297
              {isLoadingIncrementalItems && (
×
298
                <Skeleton
299
                  active
300
                  paragraph={{ rows: 1, width: '100%' }}
301
                  title={false}
302
                />
303
              )}
304
            </div>
305
          </>
306
        )
307
      }
308

309
      if (!hookedFooter) {
222✔
310
        return (
218✔
311
          <div className={styles.dropdownRenderWrapper}>
312
            {headerComponent}
313
            <div
314
              ref={scrollContainerRef}
315
              style={scrollableStyle}
316
              onScroll={handleScroll}
317
            >
318
              {dropdownBody}
319
            </div>
320
          </div>
321
        )
322
      }
323

324
      return (
4✔
325
        <div className={styles.dropdownRenderWrapper}>
326
          {headerComponent}
327
          <div
328
            ref={scrollContainerRef}
329
            style={scrollableStyle}
330
            onScroll={handleScroll}
331
          >
332
            {dropdownBody}
333
          </div>
334
          <div className={styles.footerDivider}>
335
            <Divider margin={spacing?.margin?.none} />
336
          </div>
337
          <Footer footer={hookedFooter} />
338
        </div>
339
      )
340
    },
341
    [
342
      menuItemsMaxHeight,
343
      isLoading,
344
      hasError,
345
      itemsToRender.length,
346
      isInfiniteScrollEnabled,
347
      isScrollable,
348
      hookedFooter,
349
      headerComponent,
350
      scrollContainerRef,
351
      handleScroll,
352
      spacing?.margin?.none,
353
      spacing.padding.sm,
354
      errorMessage,
355
      onRetry,
356
      searchTerm,
357
      isLoadingIncrementalItems,
358
    ]
359
  )
360

361
  const menu = useMemo(() => ({
364✔
362
    items: antdItems,
363
    /* istanbul ignore next */
364
    getPopupContainer: (triggerNode: HTMLElement) => (document.querySelector(`.${uniqueOverlayClassName}`) || triggerNode) as HTMLElement,
92!
365
    onClick: onAntdMenuClick,
366
    selectedKeys: selectedItems,
367
  }), [antdItems, onAntdMenuClick, selectedItems, uniqueOverlayClassName])
368

369
  const classes = useMemo(() => classNames(styles.dropdownWrapper, uniqueOverlayClassName), [uniqueOverlayClassName])
364✔
370

371
  const onOpenChangeInternal = useCallback(
364✔
372
    (open: boolean, info: { source: 'trigger' | 'menu' }) => {
373
      if (!open && isSearchable && searchTerm) {
128✔
374
        setSearchTerm('')
2✔
375
      }
376

377
      if (!onOpenChange) {
128✔
378
        return
114✔
379
      }
380
      onOpenChange(open, { source: antdSourceMap[info.source] })
14✔
381
    },
382
    [isSearchable, onOpenChange, searchTerm]
383
  )
384

385
  return (
364✔
386
    <AntdDropdown
387
      autoFocus={autoFocus}
388
      className={uniqueDropdownClassName}
389
      disabled={isDisabled}
390
      dropdownRender={dropdownRender}
391
      getPopupContainer={getPopupContainer}
392
      menu={menu}
393
      overlayClassName={classes}
394
      placement={placement}
395
      trigger={triggers}
396
      onOpenChange={onOpenChangeInternal}
397
    >
398
      {innerNode}
399
    </AntdDropdown>
400
  )
401
}
402

403
Dropdown.ItemLayout = ItemLayout
20✔
404
Dropdown.Trigger = DropdownTrigger
20✔
405
Dropdown.Placement = Placement
20✔
406
Dropdown.Loader = Loader
20✔
407
Dropdown.ErrorState = ErrorState
20✔
408
Dropdown.EmptyState = EmptyState
20✔
409

410
function itemsAdapter(items: DropdownItem[], layout: ItemLayout): AntdMenuItems {
411
  return items.map<AntdMenuItem>((item: DropdownItem) => ({
596✔
412
    children: item.children ? itemsAdapter(item.children, layout) : undefined,
298✔
413
    danger: item.danger,
414
    key: item.id,
415
    label: <Label item={item} layout={layout} />,
416
    disabled: item.disabled,
417
  }))
418
}
419

420
function eventAdapter(
421
  event: AntdMenuClickEvent,
422
  finder: (id: string) => DropdownItem | undefined,
423
): DropdownClickEvent {
424
  return {
26✔
425
    id: event.key,
426
    selectedPath: event.keyPath?.reverse(),
427
    domEvent: event.domEvent,
428
    item: finder(event.key),
429
  }
430
}
431

432
function itemFinder(items: DropdownItem[], id: string): DropdownItem | undefined {
433
  for (const item of items) {
32✔
434
    if (item.id === id) {
42✔
435
      return item
26✔
436
    }
437

438
    if (item.children) {
16✔
439
      const found = itemFinder(item.children, id)
6✔
440
      if (found) {
6✔
441
        return found
6✔
442
      }
443
    }
444
  }
445
}
446

447
function pushOrRemove(prevItems: string[], itemId: string): string[] {
448
  const newItems = [...prevItems]
4✔
449
  const indexOfItem = newItems.indexOf(itemId)
4✔
450
  if (indexOfItem < 0) {
4✔
451
    newItems.push(itemId)
2✔
452
    return newItems
2✔
453
  }
454

455
  newItems.splice(indexOfItem, 1)
2✔
456
  return newItems
2✔
457
}
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