diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index a217e24bfdb..94e98a059ad 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -2,7 +2,7 @@ import type {ScrollIntoViewOptions} from '@primer/behaviors' import {scrollIntoView, FocusKeys} from '@primer/behaviors' import type {KeyboardEventHandler, JSX} from 'react' import type React from 'react' -import {forwardRef, useCallback, useDeferredValue, useEffect, useRef, useState} from 'react' +import {forwardRef, memo, useCallback, useDeferredValue, useEffect, useRef, useState} from 'react' import type {TextInputProps} from '../TextInput' import TextInput from '../TextInput' import {ActionList, type ActionListProps} from '../ActionList' @@ -24,6 +24,128 @@ import {clsx} from 'clsx' const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8} +const MappedActionListItem = forwardRef((item, ref) => { + // keep backward compatibility for renderItem + // escape hatch for custom Item rendering + if (typeof item.renderItem === 'function') return item.renderItem(item) + + const { + id, + description, + descriptionVariant, + text, + trailingVisual: TrailingVisual, + leadingVisual: LeadingVisual, + trailingText, + trailingIcon: TrailingIcon, + onAction, + children, + ...rest + } = item + + return ( + | React.KeyboardEvent) => { + if (typeof onAction === 'function') + onAction(item, e as React.MouseEvent | React.KeyboardEvent) + }} + data-id={id} + ref={ref} + {...rest} + > + {LeadingVisual ? ( + + + + ) : null} + {children} + {text} + {description ? {description} : null} + {TrailingVisual ? ( + + {typeof TrailingVisual !== 'string' && isValidElementType(TrailingVisual) ? ( + + ) : ( + TrailingVisual + )} + + ) : TrailingIcon || trailingText ? ( + + {trailingText} + {TrailingIcon && } + + ) : null} + + ) +}) + +/** + * Memoized component that renders the list items. + * Using React.memo allows React to skip re-rendering when deferredItems hasn't changed yet, + * keeping the input responsive during typing. + */ +interface FilteredActionListItemsProps { + deferredItems: ItemInput[] + groupMetadata?: GroupedListProps['groupMetadata'] + getItemListForEachGroup: (groupId: string, itemsList: ItemInput[]) => ItemInput[] + isInputFocused: boolean + renderItem?: RenderItemFn +} + +const FilteredActionListItems = memo( + ({deferredItems, groupMetadata, getItemListForEachGroup, isInputFocused, renderItem}) => { + let firstGroupIndex = 0 + + return ( + <> + {groupMetadata?.length + ? groupMetadata.map((group, index) => { + if (index === firstGroupIndex && getItemListForEachGroup(group.groupId, deferredItems).length === 0) { + firstGroupIndex++ // Increment firstGroupIndex if the first group has no items + } + return ( + + + {group.header?.title ? group.header.title : `Group ${group.groupId}`} + + {getItemListForEachGroup(group.groupId, deferredItems).map(({key: itemKey, ...item}, itemIndex) => { + const key = itemKey ?? item.id?.toString() ?? itemIndex.toString() + return ( + + ) + })} + + ) + }) + : deferredItems.map(({key: itemKey, ...item}, index) => { + const key = itemKey ?? item.id?.toString() ?? index.toString() + return ( + + ) + })} + + ) + }, +) + +FilteredActionListItems.displayName = 'FilteredActionListItems' + export interface FilteredActionListProps extends Partial>, ListPropsBase { loading?: boolean loadingType?: FilteredActionListLoadingType @@ -334,7 +456,6 @@ export function FilteredActionList({ if (message) { return message } - let firstGroupIndex = 0 const actionListContent = ( - {groupMetadata?.length - ? groupMetadata.map((group, index) => { - if (index === firstGroupIndex && getItemListForEachGroup(group.groupId, deferredItems).length === 0) { - firstGroupIndex++ // Increment firstGroupIndex if the first group has no items - } - return ( - - - {group.header?.title ? group.header.title : `Group ${group.groupId}`} - - {getItemListForEachGroup(group.groupId, deferredItems).map(({key: itemKey, ...item}, itemIndex) => { - const key = itemKey ?? item.id?.toString() ?? itemIndex.toString() - return ( - - ) - })} - - ) - }) - : deferredItems.map(({key: itemKey, ...item}, index) => { - const key = itemKey ?? item.id?.toString() ?? index.toString() - return ( - - ) - })} + ) @@ -453,66 +542,11 @@ export function FilteredActionList({ )} {/* @ts-expect-error div needs a non nullable ref */}
+ {/* eslint-disable-next-line react-hooks/refs -- getBodyContent accesses scrollContainerRef.current for conditional loading indicator rendering, which is safe in this context */} {getBodyContent()}
) } -const MappedActionListItem = forwardRef((item, ref) => { - // keep backward compatibility for renderItem - // escape hatch for custom Item rendering - if (typeof item.renderItem === 'function') return item.renderItem(item) - - const { - id, - description, - descriptionVariant, - text, - trailingVisual: TrailingVisual, - leadingVisual: LeadingVisual, - trailingText, - trailingIcon: TrailingIcon, - onAction, - children, - ...rest - } = item - - return ( - | React.KeyboardEvent) => { - if (typeof onAction === 'function') - onAction(item, e as React.MouseEvent | React.KeyboardEvent) - }} - data-id={id} - ref={ref} - {...rest} - > - {LeadingVisual ? ( - - - - ) : null} - {children} - {text} - {description ? {description} : null} - {TrailingVisual ? ( - - {typeof TrailingVisual !== 'string' && isValidElementType(TrailingVisual) ? ( - - ) : ( - TrailingVisual - )} - - ) : TrailingIcon || trailingText ? ( - - {trailingText} - {TrailingIcon && } - - ) : null} - - ) -}) FilteredActionList.displayName = 'FilteredActionList'