Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/react/src/ActionList/ActionList.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 21 additions & 3 deletions packages/react/src/ActionList/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -88,13 +90,14 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
<TrailingVisual>{defaultTrailingVisual}</TrailingVisual>
) : 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))

Expand Down Expand Up @@ -165,6 +168,16 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
const keyPressHandler = React.useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
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
Expand All @@ -175,7 +188,7 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
onSelect(event, afterSelect)
}
},
[onSelect, disabled, loading, inactive, afterSelect],
[onSelect, disabled, loading, inactive, afterSelect, isMacOS],
)

const itemId = useId(id)
Expand All @@ -202,9 +215,12 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
// 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,
Expand Down Expand Up @@ -258,6 +274,7 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
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)}
>
<ItemWrapper
Expand Down Expand Up @@ -289,6 +306,7 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
{/* 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 && <VisuallyHidden>Loading</VisuallyHidden>}
{slots.trailingAction && <VisuallyHidden>{trailingActionShortcutText}</VisuallyHidden>}
</span>
{slots.description}
</ConditionalWrapper>
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/ActionList/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ const UnwrappedList = <As extends React.ElementType = 'ul'>(
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 (
Expand Down
13 changes: 11 additions & 2 deletions packages/react/src/ActionList/TrailingAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span className={clsx(className, classes.TrailingAction)} style={style}>
<span
className={clsx(className, classes.TrailingAction)}
style={style}
data-show-on-hover={showOnHover}
data-component="TrailingAction"
>
{icon ? (
<IconButton
as={as}
Expand Down
37 changes: 32 additions & 5 deletions packages/react/src/FilteredActionList/FilteredActionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import {scrollIntoView, FocusKeys} from '@primer/behaviors'
import type {KeyboardEventHandler, JSX} from 'react'
import type React from 'react'
import {useCallback, useEffect, useRef, useState} from 'react'
import {act, useCallback, useEffect, useRef, useState} from 'react'

Check failure on line 5 in packages/react/src/FilteredActionList/FilteredActionList.tsx

View workflow job for this annotation

GitHub Actions / lint

'act' is defined but never used
import type {TextInputProps} from '../TextInput'
import TextInput from '../TextInput'
import {ActionList} from '../ActionList'
Expand All @@ -23,6 +23,7 @@
import {useAnnouncements} from './useAnnouncements'
import {clsx} from 'clsx'
import {useFeatureFlag} from '../FeatureFlags'
import {useIsMacOS} from '../hooks'

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

Expand Down Expand Up @@ -93,6 +94,8 @@
const inputDescriptionTextId = useId()
const [isInputFocused, setIsInputFocused] = useState(false)

const isMacOS = useIsMacOS()

const selectAllChecked = items.length > 0 && items.every(item => item.selected)
const selectAllIndeterminate = !selectAllChecked && items.some(item => item.selected)

Expand Down Expand Up @@ -152,6 +155,16 @@

const onInputKeyPress: KeyboardEventHandler = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
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()
Expand All @@ -161,7 +174,7 @@
activeDescendantRef.current.dispatchEvent(activeDescendantEvent)
}
},
[activeDescendantRef],
[activeDescendantRef, isMacOS],
)

// BEGIN: Todo remove when we remove usingRemoveActiveDescendant
Expand All @@ -184,7 +197,10 @@
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) => {
Expand Down Expand Up @@ -347,8 +363,8 @@
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"
Expand Down Expand Up @@ -398,6 +414,7 @@
leadingVisual: LeadingVisual,
trailingText,
trailingIcon: TrailingIcon,
trailingAction,
onAction,
children,
...rest
Expand Down Expand Up @@ -436,6 +453,16 @@
{TrailingIcon && <TrailingIcon />}
</ActionList.TrailingVisual>
) : null}
{trailingAction ? (
<ActionList.TrailingAction
label={trailingAction.label}
icon={trailingAction.icon}
{...(trailingAction.as === 'a' && trailingAction.href
? {as: 'a' as const, href: trailingAction.href}
: {as: 'button' as const, loading: trailingAction.loading})}
onClick={trailingAction.onClick}
/>
) : null}
</ActionList.Item>
)
}
Expand Down
31 changes: 31 additions & 0 deletions packages/react/src/FilteredActionList/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>) => void
}

/**
* Style variations associated with various `Item` types.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -583,3 +583,83 @@ export const RenderMoreOnScroll = () => {
</form>
)
}

export const WithTrailingActions = () => {
const itemsWithTrailingActions = [
{
leadingVisual: getColorCircle('#a2eeef'),
text: 'enhancement',
id: 1,
trailingAction: {
label: 'Edit enhancement',
icon: PencilIcon,
onClick: (e: React.MouseEvent<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
e.stopPropagation()
alert('More info clicked!')
},
},
},
]

const [selected, setSelected] = useState<ItemInput[]>([itemsWithTrailingActions[0]])
const [filter, setFilter] = useState('')
const filteredItems = itemsWithTrailingActions.filter(item =>
item.text.toLowerCase().startsWith(filter.toLowerCase()),
)

const [open, setOpen] = useState(false)

return (
<FormControl>
<FormControl.Label>Labels with trailing actions</FormControl.Label>
<SelectPanel
title="Select labels"
placeholder="Select labels"
renderAnchor={({children, ...anchorProps}) => (
<Button trailingAction={TriangleDownIcon} {...anchorProps}>
{children}
</Button>
)}
open={open}
onOpenChange={setOpen}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
overlayProps={{width: 'medium'}}
message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined}
/>
</FormControl>
)
}
Loading