diff --git a/.changeset/perf-action-list-memoization.md b/.changeset/perf-action-list-memoization.md new file mode 100644 index 00000000000..bb97d9d8ce1 --- /dev/null +++ b/.changeset/perf-action-list-memoization.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +perf(ActionList): memoize context values, menuItemProps, and aria attributes diff --git a/packages/react/src/ActionList/Item.tsx b/packages/react/src/ActionList/Item.tsx index 97fbe722e2b..e6625ca0cdf 100644 --- a/packages/react/src/ActionList/Item.tsx +++ b/packages/react/src/ActionList/Item.tsx @@ -70,6 +70,19 @@ const DivItemContainerNoBox = React.forwardRef( { variant = 'default', @@ -92,14 +105,7 @@ const UnwrappedItem = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any forwardedRef: React.Ref, ): JSX.Element => { - const baseSlots = { - leadingVisual: LeadingVisual, - trailingVisual: TrailingVisual, - trailingAction: TrailingAction, - subItem: SubItem, - } - - const [partialSlots, childrenWithoutSlots] = useSlots(props.children, {...baseSlots, description: Description}) + const [partialSlots, childrenWithoutSlots] = useSlots(props.children, slotsConfig) const slots = {description: undefined, ...partialSlots} @@ -173,7 +179,6 @@ const UnwrappedItem = ( itemRole === 'menuitemcheckbox' || itemRole === 'tab' - const listRoleTypes = ['listbox', 'menu', 'list'] const listSemantics = (listRole && listRoleTypes.includes(listRole)) || inactive || listItemSemantics const buttonSemantics = !listSemantics && !_PrivateItemWrapper @@ -188,7 +193,7 @@ const UnwrappedItem = ( const keyPressHandler = React.useCallback( (event: React.KeyboardEvent) => { if (disabled || inactive || loading) return - if ([' ', 'Enter'].includes(event.key)) { + if (event.key === ' ' || event.key === 'Enter') { if (event.key === ' ') { event.preventDefault() // prevent scrolling on Space // immediately reset defaultPrevented once its job is done @@ -214,40 +219,61 @@ const UnwrappedItem = ( const ItemWrapper = _PrivateItemWrapper || DefaultItemWrapper - // only apply aria-selected and aria-checked to selectable items - const selectableRoles = ['menuitemradio', 'menuitemcheckbox', 'option'] const includeSelectionAttribute = itemSelectionAttribute && itemRole && selectableRoles.includes(itemRole) - let focusable - - if (showInactiveIndicator) { - focusable = true - } + const focusable = showInactiveIndicator ? true : undefined // Extract the variant prop value from the description slot component const descriptionVariant = slots.description?.props.variant ?? 'inline' - const menuItemProps = { - onClick: clickHandler, - onKeyPress: !buttonSemantics ? keyPressHandler : undefined, - 'aria-disabled': disabled ? true : undefined, - 'data-inactive': inactive ? true : undefined, - 'data-loading': loading && !inactive ? true : undefined, - tabIndex: focusable ? undefined : 0, - 'aria-labelledby': `${labelId} ${slots.trailingVisual ? trailingVisualId : ''}`, - 'aria-describedby': - [ - slots.description && descriptionVariant === 'block' ? blockDescriptionId : undefined, - slots.description && descriptionVariant === 'inline' ? inlineDescriptionId : undefined, - inactiveWarningId ?? undefined, - ] - .filter(String) - .join(' ') - .trim() || undefined, - ...(includeSelectionAttribute && {[itemSelectionAttribute]: selected}), - role: itemRole, - id: itemId, - } + const hasTrailingVisualSlot = Boolean(slots.trailingVisual) + const hasDescriptionSlot = Boolean(slots.description) + + const ariaLabelledBy = React.useMemo(() => { + const parts = [labelId] + if (hasTrailingVisualSlot) parts.push(trailingVisualId) + if (hasDescriptionSlot && descriptionVariant === 'inline') parts.push(inlineDescriptionId) + return parts.join(' ') + }, [labelId, hasTrailingVisualSlot, trailingVisualId, hasDescriptionSlot, descriptionVariant, inlineDescriptionId]) + + const ariaDescribedBy = React.useMemo(() => { + const parts: string[] = [] + if (hasDescriptionSlot && descriptionVariant === 'block') parts.push(blockDescriptionId) + if (inactiveWarningId) parts.push(inactiveWarningId) + return parts.length > 0 ? parts.join(' ') : undefined + }, [hasDescriptionSlot, descriptionVariant, blockDescriptionId, inactiveWarningId]) + + const menuItemProps = React.useMemo( + () => ({ + onClick: clickHandler, + onKeyPress: !buttonSemantics ? keyPressHandler : undefined, + 'aria-disabled': disabled ? true : undefined, + 'data-inactive': inactive ? true : undefined, + 'data-loading': loading && !inactive ? true : undefined, + tabIndex: focusable ? undefined : 0, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + ...(includeSelectionAttribute && {[itemSelectionAttribute]: selected}), + role: itemRole, + id: itemId, + }), + [ + clickHandler, + buttonSemantics, + keyPressHandler, + disabled, + inactive, + loading, + focusable, + ariaLabelledBy, + ariaDescribedBy, + includeSelectionAttribute, + itemSelectionAttribute, + selected, + itemRole, + itemId, + ], + ) const containerProps = _PrivateItemWrapper ? {role: itemRole ? 'none' : undefined, ...props} @@ -262,19 +288,32 @@ const UnwrappedItem = ( ref: forwardedRef, } + const itemContextValue = React.useMemo( + () => ({ + variant, + size, + disabled, + inactive: Boolean(inactiveText), + inlineDescriptionId, + blockDescriptionId, + trailingVisualId, + setTruncatedText: buttonSemantics ? setTruncatedText : undefined, + }), + [ + variant, + size, + disabled, + inactiveText, + inlineDescriptionId, + blockDescriptionId, + trailingVisualId, + buttonSemantics, + setTruncatedText, + ], + ) + return ( - +
  • ( listRole === 'menu' || container === 'SelectPanel' || container === 'FilteredActionList' ? 'wrap' : undefined, }) + const listContextValue = React.useMemo( + () => ({ + variant, + selectionVariant: selectionVariant || containerSelectionVariant, + showDividers, + role: listRole, + headingId, + }), + [variant, selectionVariant, containerSelectionVariant, showDividers, listRole, headingId], + ) + return ( - + {slots.heading} {/* @ts-expect-error ref needs a non nullable ref */}