Skip to content

Commit bcaf9df

Browse files
committed
Add example of trailing action support
1 parent 21cefb9 commit bcaf9df

File tree

7 files changed

+194
-11
lines changed

7 files changed

+194
-11
lines changed

packages/react/src/ActionList/ActionList.module.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,19 @@
361361
}
362362
}
363363

364+
&:has(.TrailingAction[data-show-on-hover='true']),
365+
&[data-is-active-descendant] {
366+
.TrailingAction {
367+
display: none;
368+
}
369+
370+
&:hover .TrailingAction,
371+
&:focus-within .TrailingAction,
372+
&[data-is-active-descendant] .TrailingAction {
373+
display: inherit;
374+
}
375+
}
376+
364377
/* Make sure that the first visible item isn't a divider */
365378
&[aria-hidden] + .Divider {
366379
display: none;
@@ -760,6 +773,8 @@ span wrapping svg or text */
760773
grid-row: 2/2;
761774
}
762775

776+
777+
763778
@keyframes checkmarkIn {
764779
from {
765780
clip-path: inset(var(--base-size-16) 0 0 0);

packages/react/src/ActionList/Item.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import VisuallyHidden from '../_VisuallyHidden'
1616
import classes from './ActionList.module.css'
1717
import {clsx} from 'clsx'
1818
import {fixedForwardRef} from '../utils/modern-polymorphic'
19+
import {useIsMacOS} from '../hooks'
20+
import {getAccessibleKeybindingHintString} from '../KeybindingHint'
1921

2022
type ActionListSubItemProps = {
2123
children?: React.ReactNode
@@ -88,13 +90,14 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
8890
<TrailingVisual>{defaultTrailingVisual}</TrailingVisual>
8991
) : null
9092
const trailingVisual = slots.trailingVisual ?? wrappedDefaultTrailingVisual
93+
const isMacOS = useIsMacOS()
9194

9295
const {role: listRole, selectionVariant: listSelectionVariant} = React.useContext(ListContext)
9396
const {selectionVariant: groupSelectionVariant} = React.useContext(GroupContext)
9497
const inactive = Boolean(inactiveText)
9598
// TODO change `menuContext` check to ```listRole !== undefined && ['menu', 'listbox'].includes(listRole)```
9699
// once we have a better way to handle existing usage in dotcom that incorrectly use ActionList.TrailingAction
97-
const menuContext = container === 'ActionMenu' || container === 'SelectPanel' || container === 'FilteredActionList'
100+
const menuContext = container === 'ActionMenu'
98101
// TODO: when we change `menuContext` to check `listRole` instead of `container`
99102
const showInactiveIndicator = inactive && !(listRole !== undefined && ['menu', 'listbox'].includes(listRole))
100103

@@ -165,6 +168,16 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
165168
const keyPressHandler = React.useCallback(
166169
(event: React.KeyboardEvent<HTMLElement>) => {
167170
if (disabled || inactive || loading) return
171+
172+
// TODO: Move this logic to `filteredActionList`
173+
if (event.key === 'U' || event.key === 'u') {
174+
if (event.shiftKey && (isMacOS ? event.metaKey : event.ctrlKey)) {
175+
event.preventDefault()
176+
alert('Activated Trailing Action')
177+
// do some action ...
178+
}
179+
}
180+
168181
if ([' ', 'Enter'].includes(event.key)) {
169182
if (event.key === ' ') {
170183
event.preventDefault() // prevent scrolling on Space
@@ -175,7 +188,7 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
175188
onSelect(event, afterSelect)
176189
}
177190
},
178-
[onSelect, disabled, loading, inactive, afterSelect],
191+
[onSelect, disabled, loading, inactive, afterSelect, isMacOS],
179192
)
180193

181194
const itemId = useId(id)
@@ -202,9 +215,12 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
202215
// Extract the variant prop value from the description slot component
203216
const descriptionVariant = slots.description?.props.variant ?? 'inline'
204217

218+
const shortcut = `Shift+${isMacOS ? 'Meta' : 'Control'}+U`
219+
const trailingActionShortcutText = `(press ${getAccessibleKeybindingHintString(shortcut, isMacOS)} for more actions)`
220+
205221
const menuItemProps = {
206222
onClick: clickHandler,
207-
onKeyPress: !buttonSemantics ? keyPressHandler : undefined,
223+
onKeyDown: !buttonSemantics ? keyPressHandler : undefined,
208224
'aria-disabled': disabled ? true : undefined,
209225
'data-inactive': inactive ? true : undefined,
210226
'data-loading': loading && !inactive ? true : undefined,
@@ -258,6 +274,7 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
258274
data-inactive={inactiveText ? true : undefined}
259275
data-has-subitem={slots.subItem ? true : undefined}
260276
data-has-description={slots.description ? true : false}
277+
data-has-trailing-action={slots.trailingAction ? true : undefined}
261278
className={clsx(classes.ActionListItem, className)}
262279
>
263280
<ItemWrapper
@@ -289,6 +306,7 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
289306
{/* Loading message needs to be in here so it is read with the label */}
290307
{/* If the item is inactive, we do not simultaneously announce that it is loading */}
291308
{loading === true && !inactive && <VisuallyHidden>Loading</VisuallyHidden>}
309+
{slots.trailingAction && <VisuallyHidden>{trailingActionShortcutText}</VisuallyHidden>}
292310
</span>
293311
{slots.description}
294312
</ConditionalWrapper>

packages/react/src/ActionList/List.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ const UnwrappedList = <As extends React.ElementType = 'ul'>(
5353
bindKeys: FocusKeys.ArrowVertical | FocusKeys.HomeAndEnd | FocusKeys.PageUpDown,
5454
focusOutBehavior:
5555
listRole === 'menu' || container === 'SelectPanel' || container === 'FilteredActionList' ? 'wrap' : undefined,
56+
focusableElementFilter: element => {
57+
return !(element.parentElement?.getAttribute('data-component') === 'TrailingAction')
58+
},
5659
})
5760

5861
return (

packages/react/src/ActionList/TrailingAction.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,21 @@ export type ActionListTrailingActionProps = ElementProps & {
2626
label: string
2727
className?: string
2828
style?: React.CSSProperties
29+
showOnHover?: boolean
2930
}
3031

3132
export const TrailingAction = forwardRef(
32-
({as = 'button', icon, label, href = null, className, style, loading, ...props}, forwardedRef) => {
33+
(
34+
{as = 'button', icon, label, href = null, className, style, loading, showOnHover = true, ...props},
35+
forwardedRef,
36+
) => {
3337
return (
34-
<span className={clsx(className, classes.TrailingAction)} style={style}>
38+
<span
39+
className={clsx(className, classes.TrailingAction)}
40+
style={style}
41+
data-show-on-hover={showOnHover}
42+
data-component="TrailingAction"
43+
>
3544
{icon ? (
3645
<IconButton
3746
as={as}

packages/react/src/FilteredActionList/FilteredActionList.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type {ScrollIntoViewOptions} from '@primer/behaviors'
22
import {scrollIntoView, FocusKeys} from '@primer/behaviors'
33
import type {KeyboardEventHandler, JSX} from 'react'
44
import type React from 'react'
5-
import {useCallback, useEffect, useRef, useState} from 'react'
5+
import {act, useCallback, useEffect, useRef, useState} from 'react'
66
import type {TextInputProps} from '../TextInput'
77
import TextInput from '../TextInput'
88
import {ActionList} from '../ActionList'
@@ -23,6 +23,7 @@ import {isValidElementType} from 'react-is'
2323
import {useAnnouncements} from './useAnnouncements'
2424
import {clsx} from 'clsx'
2525
import {useFeatureFlag} from '../FeatureFlags'
26+
import {useIsMacOS} from '../hooks'
2627

2728
const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8}
2829

@@ -93,6 +94,8 @@ export function FilteredActionList({
9394
const inputDescriptionTextId = useId()
9495
const [isInputFocused, setIsInputFocused] = useState(false)
9596

97+
const isMacOS = useIsMacOS()
98+
9699
const selectAllChecked = items.length > 0 && items.every(item => item.selected)
97100
const selectAllIndeterminate = !selectAllChecked && items.some(item => item.selected)
98101

@@ -152,6 +155,16 @@ export function FilteredActionList({
152155

153156
const onInputKeyPress: KeyboardEventHandler = useCallback(
154157
(event: React.KeyboardEvent<HTMLInputElement>) => {
158+
if (event.key === 'U' || event.key === 'u') {
159+
if (event.shiftKey && (isMacOS ? event.metaKey : event.ctrlKey)) {
160+
if (!activeDescendantRef.current?.hasAttribute('data-has-trailing-action')) return
161+
162+
event.preventDefault()
163+
alert('Activated Trailing Action')
164+
// do some action ...
165+
}
166+
}
167+
155168
if (event.key === 'Enter' && activeDescendantRef.current) {
156169
event.preventDefault()
157170
event.nativeEvent.stopImmediatePropagation()
@@ -161,7 +174,7 @@ export function FilteredActionList({
161174
activeDescendantRef.current.dispatchEvent(activeDescendantEvent)
162175
}
163176
},
164-
[activeDescendantRef],
177+
[activeDescendantRef, isMacOS],
165178
)
166179

167180
// BEGIN: Todo remove when we remove usingRemoveActiveDescendant
@@ -184,7 +197,10 @@ export function FilteredActionList({
184197
bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown,
185198
focusOutBehavior: 'wrap',
186199
focusableElementFilter: element => {
187-
return !(element instanceof HTMLInputElement)
200+
return (
201+
!(element instanceof HTMLInputElement) &&
202+
!(element.parentElement?.getAttribute('data-component') === 'TrailingAction')
203+
)
188204
},
189205
activeDescendantFocus: inputRef,
190206
onActiveDescendantChanged: (current, previous, directlyActivated) => {
@@ -347,8 +363,8 @@ export function FilteredActionList({
347363
color="fg.default"
348364
value={filterValue}
349365
onChange={onInputChange}
350-
onKeyPress={onInputKeyPress}
351-
onKeyDown={usingRemoveActiveDescendant ? onInputKeyDown : () => {}}
366+
// onKeyPress={onInputKeyPress}
367+
onKeyDown={usingRemoveActiveDescendant ? onInputKeyDown : onInputKeyPress}
352368
placeholder={placeholderText}
353369
role="combobox"
354370
aria-expanded="true"
@@ -398,6 +414,7 @@ function MappedActionListItem(item: ItemInput & {renderItem?: RenderItemFn}) {
398414
leadingVisual: LeadingVisual,
399415
trailingText,
400416
trailingIcon: TrailingIcon,
417+
trailingAction,
401418
onAction,
402419
children,
403420
...rest
@@ -436,6 +453,16 @@ function MappedActionListItem(item: ItemInput & {renderItem?: RenderItemFn}) {
436453
{TrailingIcon && <TrailingIcon />}
437454
</ActionList.TrailingVisual>
438455
) : null}
456+
{trailingAction ? (
457+
<ActionList.TrailingAction
458+
label={trailingAction.label}
459+
icon={trailingAction.icon}
460+
{...(trailingAction.as === 'a' && trailingAction.href
461+
? {as: 'a' as const, href: trailingAction.href}
462+
: {as: 'button' as const, loading: trailingAction.loading})}
463+
onClick={trailingAction.onClick}
464+
/>
465+
) : null}
439466
</ActionList.Item>
440467
)
441468
}

packages/react/src/FilteredActionList/types.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,37 @@ export interface FilteredActionListItemProps {
5151
*/
5252
trailingVisual?: React.ElementType | React.ReactNode
5353

54+
/**
55+
* An action positioned after the `Item` text. This is a button or link that appears at the end of the item.
56+
* Only available for items in SelectPanel (not available in ActionMenu or other contexts with menu/listbox roles).
57+
*/
58+
trailingAction?: {
59+
/**
60+
* The label for the action button. Used as aria-label if icon is provided, or as button text if no icon.
61+
*/
62+
label: string
63+
/**
64+
* Optional icon to display in the action button.
65+
*/
66+
icon?: React.ElementType
67+
/**
68+
* The element type to render. Defaults to 'button'.
69+
*/
70+
as?: 'button' | 'a'
71+
/**
72+
* The href for the action when rendered as a link (as='a').
73+
*/
74+
href?: string
75+
/**
76+
* Whether the action is in a loading state. Only available for button elements.
77+
*/
78+
loading?: boolean
79+
/**
80+
* onClick handler for the action.
81+
*/
82+
onClick?: (event: React.MouseEvent<HTMLElement>) => void
83+
}
84+
5485
/**
5586
* Style variations associated with various `Item` types.
5687
*

packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {Button} from '../Button'
44
import type {ItemInput} from '../FilteredActionList'
55
import {SelectPanel} from './SelectPanel'
66
import type {OverlayProps} from '../Overlay'
7-
import {TriangleDownIcon} from '@primer/octicons-react'
7+
import {TriangleDownIcon, PencilIcon, TrashIcon} from '@primer/octicons-react'
88
import {ActionList} from '../ActionList'
99
import FormControl from '../FormControl'
1010
import {Stack} from '../Stack'
@@ -583,3 +583,83 @@ export const RenderMoreOnScroll = () => {
583583
</form>
584584
)
585585
}
586+
587+
export const WithTrailingActions = () => {
588+
const itemsWithTrailingActions = [
589+
{
590+
leadingVisual: getColorCircle('#a2eeef'),
591+
text: 'enhancement',
592+
id: 1,
593+
trailingAction: {
594+
label: 'Edit enhancement',
595+
icon: PencilIcon,
596+
onClick: (e: React.MouseEvent<HTMLElement>) => {
597+
e.stopPropagation()
598+
alert('Edit enhancement clicked!')
599+
},
600+
},
601+
},
602+
{
603+
leadingVisual: getColorCircle('#d73a4a'),
604+
text: 'bug',
605+
id: 2,
606+
},
607+
{
608+
leadingVisual: getColorCircle('#0cf478'),
609+
text: 'good first issue',
610+
id: 3,
611+
trailingAction: {
612+
label: 'Remove label',
613+
icon: TrashIcon,
614+
onClick: (e: React.MouseEvent<HTMLElement>) => {
615+
e.stopPropagation()
616+
alert('Remove label clicked!')
617+
},
618+
},
619+
},
620+
{
621+
leadingVisual: getColorCircle('#ffd78e'),
622+
text: 'design',
623+
id: 4,
624+
trailingAction: {
625+
label: 'More info',
626+
as: 'button' as const,
627+
onClick: (e: React.MouseEvent<HTMLElement>) => {
628+
e.stopPropagation()
629+
alert('More info clicked!')
630+
},
631+
},
632+
},
633+
]
634+
635+
const [selected, setSelected] = useState<ItemInput[]>([itemsWithTrailingActions[0]])
636+
const [filter, setFilter] = useState('')
637+
const filteredItems = itemsWithTrailingActions.filter(item =>
638+
item.text.toLowerCase().startsWith(filter.toLowerCase()),
639+
)
640+
641+
const [open, setOpen] = useState(false)
642+
643+
return (
644+
<FormControl>
645+
<FormControl.Label>Labels with trailing actions</FormControl.Label>
646+
<SelectPanel
647+
title="Select labels"
648+
placeholder="Select labels"
649+
renderAnchor={({children, ...anchorProps}) => (
650+
<Button trailingAction={TriangleDownIcon} {...anchorProps}>
651+
{children}
652+
</Button>
653+
)}
654+
open={open}
655+
onOpenChange={setOpen}
656+
items={filteredItems}
657+
selected={selected}
658+
onSelectedChange={setSelected}
659+
onFilterChange={setFilter}
660+
overlayProps={{width: 'medium'}}
661+
message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined}
662+
/>
663+
</FormControl>
664+
)
665+
}

0 commit comments

Comments
 (0)