From bcaf9dfae12c60d0785aff08aa2bb5de20785f48 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Mon, 10 Nov 2025 17:40:00 -0500 Subject: [PATCH] Add example of trailing action support --- .../src/ActionList/ActionList.module.css | 15 ++++ packages/react/src/ActionList/Item.tsx | 24 +++++- packages/react/src/ActionList/List.tsx | 3 + .../react/src/ActionList/TrailingAction.tsx | 13 ++- .../FilteredActionList/FilteredActionList.tsx | 37 +++++++-- .../react/src/FilteredActionList/types.ts | 31 +++++++ .../SelectPanel.examples.stories.tsx | 82 ++++++++++++++++++- 7 files changed, 194 insertions(+), 11 deletions(-) diff --git a/packages/react/src/ActionList/ActionList.module.css b/packages/react/src/ActionList/ActionList.module.css index 64feb5d8d88..1126f3fc96b 100644 --- a/packages/react/src/ActionList/ActionList.module.css +++ b/packages/react/src/ActionList/ActionList.module.css @@ -361,6 +361,19 @@ } } + &:has(.TrailingAction[data-show-on-hover='true']), + &[data-is-active-descendant] { + .TrailingAction { + display: none; + } + + &:hover .TrailingAction, + &:focus-within .TrailingAction, + &[data-is-active-descendant] .TrailingAction { + display: inherit; + } + } + /* Make sure that the first visible item isn't a divider */ &[aria-hidden] + .Divider { display: none; @@ -760,6 +773,8 @@ span wrapping svg or text */ grid-row: 2/2; } + + @keyframes checkmarkIn { from { clip-path: inset(var(--base-size-16) 0 0 0); diff --git a/packages/react/src/ActionList/Item.tsx b/packages/react/src/ActionList/Item.tsx index ac917743fd5..50cc576f0d4 100644 --- a/packages/react/src/ActionList/Item.tsx +++ b/packages/react/src/ActionList/Item.tsx @@ -16,6 +16,8 @@ import VisuallyHidden from '../_VisuallyHidden' import classes from './ActionList.module.css' import {clsx} from 'clsx' import {fixedForwardRef} from '../utils/modern-polymorphic' +import {useIsMacOS} from '../hooks' +import {getAccessibleKeybindingHintString} from '../KeybindingHint' type ActionListSubItemProps = { children?: React.ReactNode @@ -88,13 +90,14 @@ const UnwrappedItem = ( {defaultTrailingVisual} ) : null const trailingVisual = slots.trailingVisual ?? wrappedDefaultTrailingVisual + const isMacOS = useIsMacOS() const {role: listRole, selectionVariant: listSelectionVariant} = React.useContext(ListContext) const {selectionVariant: groupSelectionVariant} = React.useContext(GroupContext) const inactive = Boolean(inactiveText) // TODO change `menuContext` check to ```listRole !== undefined && ['menu', 'listbox'].includes(listRole)``` // once we have a better way to handle existing usage in dotcom that incorrectly use ActionList.TrailingAction - const menuContext = container === 'ActionMenu' || container === 'SelectPanel' || container === 'FilteredActionList' + const menuContext = container === 'ActionMenu' // TODO: when we change `menuContext` to check `listRole` instead of `container` const showInactiveIndicator = inactive && !(listRole !== undefined && ['menu', 'listbox'].includes(listRole)) @@ -165,6 +168,16 @@ const UnwrappedItem = ( const keyPressHandler = React.useCallback( (event: React.KeyboardEvent) => { if (disabled || inactive || loading) return + + // TODO: Move this logic to `filteredActionList` + if (event.key === 'U' || event.key === 'u') { + if (event.shiftKey && (isMacOS ? event.metaKey : event.ctrlKey)) { + event.preventDefault() + alert('Activated Trailing Action') + // do some action ... + } + } + if ([' ', 'Enter'].includes(event.key)) { if (event.key === ' ') { event.preventDefault() // prevent scrolling on Space @@ -175,7 +188,7 @@ const UnwrappedItem = ( onSelect(event, afterSelect) } }, - [onSelect, disabled, loading, inactive, afterSelect], + [onSelect, disabled, loading, inactive, afterSelect, isMacOS], ) const itemId = useId(id) @@ -202,9 +215,12 @@ const UnwrappedItem = ( // Extract the variant prop value from the description slot component const descriptionVariant = slots.description?.props.variant ?? 'inline' + const shortcut = `Shift+${isMacOS ? 'Meta' : 'Control'}+U` + const trailingActionShortcutText = `(press ${getAccessibleKeybindingHintString(shortcut, isMacOS)} for more actions)` + const menuItemProps = { onClick: clickHandler, - onKeyPress: !buttonSemantics ? keyPressHandler : undefined, + onKeyDown: !buttonSemantics ? keyPressHandler : undefined, 'aria-disabled': disabled ? true : undefined, 'data-inactive': inactive ? true : undefined, 'data-loading': loading && !inactive ? true : undefined, @@ -258,6 +274,7 @@ const UnwrappedItem = ( data-inactive={inactiveText ? true : undefined} data-has-subitem={slots.subItem ? true : undefined} data-has-description={slots.description ? true : false} + data-has-trailing-action={slots.trailingAction ? true : undefined} className={clsx(classes.ActionListItem, className)} > ( {/* Loading message needs to be in here so it is read with the label */} {/* If the item is inactive, we do not simultaneously announce that it is loading */} {loading === true && !inactive && Loading} + {slots.trailingAction && {trailingActionShortcutText}} {slots.description} diff --git a/packages/react/src/ActionList/List.tsx b/packages/react/src/ActionList/List.tsx index 0546d85abca..dd4ac64bede 100644 --- a/packages/react/src/ActionList/List.tsx +++ b/packages/react/src/ActionList/List.tsx @@ -53,6 +53,9 @@ const UnwrappedList = ( bindKeys: FocusKeys.ArrowVertical | FocusKeys.HomeAndEnd | FocusKeys.PageUpDown, focusOutBehavior: listRole === 'menu' || container === 'SelectPanel' || container === 'FilteredActionList' ? 'wrap' : undefined, + focusableElementFilter: element => { + return !(element.parentElement?.getAttribute('data-component') === 'TrailingAction') + }, }) return ( diff --git a/packages/react/src/ActionList/TrailingAction.tsx b/packages/react/src/ActionList/TrailingAction.tsx index 933c3a6f584..b922a01471d 100644 --- a/packages/react/src/ActionList/TrailingAction.tsx +++ b/packages/react/src/ActionList/TrailingAction.tsx @@ -26,12 +26,21 @@ export type ActionListTrailingActionProps = ElementProps & { label: string className?: string style?: React.CSSProperties + showOnHover?: boolean } export const TrailingAction = forwardRef( - ({as = 'button', icon, label, href = null, className, style, loading, ...props}, forwardedRef) => { + ( + {as = 'button', icon, label, href = null, className, style, loading, showOnHover = true, ...props}, + forwardedRef, + ) => { return ( - + {icon ? ( 0 && items.every(item => item.selected) const selectAllIndeterminate = !selectAllChecked && items.some(item => item.selected) @@ -152,6 +155,16 @@ export function FilteredActionList({ const onInputKeyPress: KeyboardEventHandler = useCallback( (event: React.KeyboardEvent) => { + if (event.key === 'U' || event.key === 'u') { + if (event.shiftKey && (isMacOS ? event.metaKey : event.ctrlKey)) { + if (!activeDescendantRef.current?.hasAttribute('data-has-trailing-action')) return + + event.preventDefault() + alert('Activated Trailing Action') + // do some action ... + } + } + if (event.key === 'Enter' && activeDescendantRef.current) { event.preventDefault() event.nativeEvent.stopImmediatePropagation() @@ -161,7 +174,7 @@ export function FilteredActionList({ activeDescendantRef.current.dispatchEvent(activeDescendantEvent) } }, - [activeDescendantRef], + [activeDescendantRef, isMacOS], ) // BEGIN: Todo remove when we remove usingRemoveActiveDescendant @@ -184,7 +197,10 @@ export function FilteredActionList({ bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown, focusOutBehavior: 'wrap', focusableElementFilter: element => { - return !(element instanceof HTMLInputElement) + return ( + !(element instanceof HTMLInputElement) && + !(element.parentElement?.getAttribute('data-component') === 'TrailingAction') + ) }, activeDescendantFocus: inputRef, onActiveDescendantChanged: (current, previous, directlyActivated) => { @@ -347,8 +363,8 @@ export function FilteredActionList({ color="fg.default" value={filterValue} onChange={onInputChange} - onKeyPress={onInputKeyPress} - onKeyDown={usingRemoveActiveDescendant ? onInputKeyDown : () => {}} + // onKeyPress={onInputKeyPress} + onKeyDown={usingRemoveActiveDescendant ? onInputKeyDown : onInputKeyPress} placeholder={placeholderText} role="combobox" aria-expanded="true" @@ -398,6 +414,7 @@ function MappedActionListItem(item: ItemInput & {renderItem?: RenderItemFn}) { leadingVisual: LeadingVisual, trailingText, trailingIcon: TrailingIcon, + trailingAction, onAction, children, ...rest @@ -436,6 +453,16 @@ function MappedActionListItem(item: ItemInput & {renderItem?: RenderItemFn}) { {TrailingIcon && } ) : null} + {trailingAction ? ( + + ) : null} ) } diff --git a/packages/react/src/FilteredActionList/types.ts b/packages/react/src/FilteredActionList/types.ts index 68e0ca62458..1ed3a674d67 100644 --- a/packages/react/src/FilteredActionList/types.ts +++ b/packages/react/src/FilteredActionList/types.ts @@ -51,6 +51,37 @@ export interface FilteredActionListItemProps { */ trailingVisual?: React.ElementType | React.ReactNode + /** + * An action positioned after the `Item` text. This is a button or link that appears at the end of the item. + * Only available for items in SelectPanel (not available in ActionMenu or other contexts with menu/listbox roles). + */ + trailingAction?: { + /** + * The label for the action button. Used as aria-label if icon is provided, or as button text if no icon. + */ + label: string + /** + * Optional icon to display in the action button. + */ + icon?: React.ElementType + /** + * The element type to render. Defaults to 'button'. + */ + as?: 'button' | 'a' + /** + * The href for the action when rendered as a link (as='a'). + */ + href?: string + /** + * Whether the action is in a loading state. Only available for button elements. + */ + loading?: boolean + /** + * onClick handler for the action. + */ + onClick?: (event: React.MouseEvent) => void + } + /** * Style variations associated with various `Item` types. * diff --git a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx index 3141591f221..541e581e9d0 100644 --- a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx @@ -4,7 +4,7 @@ import {Button} from '../Button' import type {ItemInput} from '../FilteredActionList' import {SelectPanel} from './SelectPanel' import type {OverlayProps} from '../Overlay' -import {TriangleDownIcon} from '@primer/octicons-react' +import {TriangleDownIcon, PencilIcon, TrashIcon} from '@primer/octicons-react' import {ActionList} from '../ActionList' import FormControl from '../FormControl' import {Stack} from '../Stack' @@ -583,3 +583,83 @@ export const RenderMoreOnScroll = () => { ) } + +export const WithTrailingActions = () => { + const itemsWithTrailingActions = [ + { + leadingVisual: getColorCircle('#a2eeef'), + text: 'enhancement', + id: 1, + trailingAction: { + label: 'Edit enhancement', + icon: PencilIcon, + onClick: (e: React.MouseEvent) => { + e.stopPropagation() + alert('Edit enhancement clicked!') + }, + }, + }, + { + leadingVisual: getColorCircle('#d73a4a'), + text: 'bug', + id: 2, + }, + { + leadingVisual: getColorCircle('#0cf478'), + text: 'good first issue', + id: 3, + trailingAction: { + label: 'Remove label', + icon: TrashIcon, + onClick: (e: React.MouseEvent) => { + e.stopPropagation() + alert('Remove label clicked!') + }, + }, + }, + { + leadingVisual: getColorCircle('#ffd78e'), + text: 'design', + id: 4, + trailingAction: { + label: 'More info', + as: 'button' as const, + onClick: (e: React.MouseEvent) => { + e.stopPropagation() + alert('More info clicked!') + }, + }, + }, + ] + + const [selected, setSelected] = useState([itemsWithTrailingActions[0]]) + const [filter, setFilter] = useState('') + const filteredItems = itemsWithTrailingActions.filter(item => + item.text.toLowerCase().startsWith(filter.toLowerCase()), + ) + + const [open, setOpen] = useState(false) + + return ( + + Labels with trailing actions + ( + + )} + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={setFilter} + overlayProps={{width: 'medium'}} + message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined} + /> + + ) +}