Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/calm-hoops-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Add loading support to ActionList.TrailingAction component.
6 changes: 6 additions & 0 deletions packages/react/src/ActionList/ActionList.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@
"name": "href",
"type": "string",
"description": "href when the TrailingAction is rendered as a link."
},
{
"name": "loading",
"type": "boolean",
"defaultValue": "false",
"description": "Whether the TrailingAction is in a loading state. When true, the TrailingAction will render a spinner instead of an icon. Only available when `as` is 'button'."
}
]
},
Expand Down
154 changes: 96 additions & 58 deletions packages/react/src/ActionList/ActionList.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -839,64 +839,102 @@ export const WithCustomTrailingVisuals = () => (
</ActionList>
)

// removing this until CSS Modules FF ships, currently broken in production if button semantic FF is false
// export const WithTrailingAction = () => {
// return (
// <FeatureFlags flags={{primer_react_action_list_item_as_button: true}}>
// <ActionList>
// <ActionList.Item>
// <ActionList.LeadingVisual>
// <FileDirectoryIcon />
// </ActionList.LeadingVisual>
// Item 1 (with default TrailingAction)
// <ActionList.TrailingAction label="Expand sidebar" icon={ArrowLeftIcon} />
// </ActionList.Item>
// <ActionList.Item>
// Item 2 (with link TrailingAction)
// <ActionList.TrailingAction as="a" href="#" label="Some action 1" icon={ArrowRightIcon} />
// </ActionList.Item>
// <ActionList.Item>
// Item 3<ActionList.Description>This is an inline description.</ActionList.Description>
// <ActionList.TrailingAction label="Some action 2" icon={BookIcon} />
// </ActionList.Item>
// <ActionList.Item>
// Item 4<ActionList.Description variant="block">This is a block description.</ActionList.Description>
// <ActionList.TrailingAction label="Some action 3" icon={BookIcon} />
// </ActionList.Item>
// <ActionList.Item>
// Item 5<ActionList.Description variant="block">This is a block description.</ActionList.Description>
// <ActionList.TrailingAction label="Some action 4" />
// </ActionList.Item>
// <ActionList.Item>
// Item 6
// <ActionList.TrailingAction href="#" as="a" label="Some action 5" />
// </ActionList.Item>
// <ActionList.LinkItem href="#">
// LinkItem 1
// <ActionList.Description>
// with TrailingAction this is a long description and should not cause horizontal scroll on smaller screen
// sizes
// </ActionList.Description>
// <ActionList.TrailingAction label="Another action" />
// </ActionList.LinkItem>
// <ActionList.LinkItem href="#">
// LinkItem 2
// <ActionList.Description>
// with TrailingVisual this is a long description and should not cause horizontal scroll on smaller screen
// sizes
// </ActionList.Description>
// <ActionList.TrailingVisual>
// <TableIcon />
// </ActionList.TrailingVisual>
// </ActionList.LinkItem>
// <ActionList.Item inactiveText="Unavailable due to an outage">
// Inactive Item<ActionList.Description>With TrailingAction</ActionList.Description>
// <ActionList.TrailingAction as="a" href="#" label="Some action 8" icon={ArrowRightIcon} />
// </ActionList.Item>
// </ActionList>
// </FeatureFlags>
// )
// }
export const WithTrailingAction = () => {
const [loadingState, setLoadingState] = React.useState(false)

// Auto-toggle every 2.5 seconds to continuously show transitions
React.useEffect(() => {
const interval = setInterval(() => {
setLoadingState(prev => !prev)
}, 2500)

return () => clearInterval(interval)
}, [])

return (
<FeatureFlags flags={{primer_react_action_list_item_as_button: true}}>
<ActionList>
<ActionList.Item>
<ActionList.LeadingVisual>
<FileDirectoryIcon />
</ActionList.LeadingVisual>
Item 1 (with default TrailingAction)
<ActionList.TrailingAction label="Expand sidebar" icon={ArrowLeftIcon} />
</ActionList.Item>
<ActionList.Item>
Item 2 (with link TrailingAction)
<ActionList.TrailingAction as="a" href="#" label="Some action 1" icon={ArrowRightIcon} />
</ActionList.Item>
<ActionList.Item>
Item 3<ActionList.Description>This is an inline description.</ActionList.Description>
<ActionList.TrailingAction label="Some action 2" icon={BookIcon} />
</ActionList.Item>
<ActionList.Item>
Item 4<ActionList.Description variant="block">This is a block description.</ActionList.Description>
<ActionList.TrailingAction label="Some action 3" icon={BookIcon} />
</ActionList.Item>
<ActionList.Item>
Item 5<ActionList.Description variant="block">This is a block description.</ActionList.Description>
<ActionList.TrailingAction label="Some action 4" />
</ActionList.Item>
<ActionList.Item>
Item 6
<ActionList.TrailingAction href="#" as="a" label="Some action 5" />
</ActionList.Item>
<ActionList.Item>
Icon button loading state
<ActionList.Description>
Shows how IconButton maintains width and centers spinner when loading
</ActionList.Description>
<ActionList.TrailingAction label="Process item" icon={ArrowRightIcon} loading />
</ActionList.Item>
<ActionList.Item>
Icon button with transitions
<ActionList.Description>
Automatically toggles loading state every 2.5 seconds to show transitions
</ActionList.Description>
<ActionList.TrailingAction label="Toggle loading" icon={ArrowRightIcon} loading={loadingState} />
</ActionList.Item>
<ActionList.Item>
Text button loading state
<ActionList.Description>
Shows how text button aligns spinner to the right and preserves width
</ActionList.Description>
<ActionList.TrailingAction label="Save changes" loading />
</ActionList.Item>
<ActionList.Item>
Text button with transitions
<ActionList.Description>
Automatically toggles loading state every 2.5 seconds to show transitions
</ActionList.Description>
<ActionList.TrailingAction label="Apply settings" loading={loadingState} />
</ActionList.Item>
<ActionList.LinkItem href="#">
LinkItem 1
<ActionList.Description>
with TrailingAction this is a long description and should not cause horizontal scroll on smaller screen
sizes
</ActionList.Description>
<ActionList.TrailingAction label="Another action" />
</ActionList.LinkItem>
<ActionList.LinkItem href="#">
LinkItem 2
<ActionList.Description>
with TrailingVisual this is a long description and should not cause horizontal scroll on smaller screen
sizes
</ActionList.Description>
<ActionList.TrailingVisual>
<TableIcon />
</ActionList.TrailingVisual>
</ActionList.LinkItem>
<ActionList.Item inactiveText="Unavailable due to an outage">
Inactive Item<ActionList.Description>With TrailingAction</ActionList.Description>
<ActionList.TrailingAction as="a" href="#" label="Some action 8" icon={ArrowRightIcon} />
</ActionList.Item>
</ActionList>
</FeatureFlags>
)
}

export const FullVariant = () => (
<ActionList variant="full">
Expand Down
47 changes: 41 additions & 6 deletions packages/react/src/ActionList/ActionList.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@
:focus,
&:focus-visible,
/* stylelint-disable-next-line selector-no-qualifying-type */
> a.focus-visible,
>a.focus-visible,
&[data-is-active-descendant] {
/* Support for Windows high contrast https://sarahmhigley.com/writing/whcm-quick-tips */
outline: solid 1px transparent !important;
Expand Down Expand Up @@ -342,6 +342,18 @@
}
}

/* When TrailingAction is in loading state, keep labels and descriptions accessible */
&:has(.TrailingAction [data-loading='true']):not([aria-disabled='true']) {
/* Ensure labels and descriptions maintain accessibility contrast */
& .ItemLabel {
color: var(--fgColor-default);
}

& .Description {
color: var(--fgColor-default);
}
}

/* Make sure that the first visible item isn't a divider */
&[aria-hidden] + .Divider {
display: none;
Expand All @@ -364,7 +376,8 @@
border-radius: var(--borderRadius-small);
transition:
background-color,
border-color 80ms cubic-bezier(0.33, 1, 0.68, 1); /* checked -> unchecked - add 120ms delay to fully see animation-out */
border-color 80ms cubic-bezier(0.33, 1, 0.68, 1);
/* checked -> unchecked - add 120ms delay to fully see animation-out */

place-content: center;

Expand All @@ -382,7 +395,8 @@
mask-size: 75%;
mask-repeat: no-repeat;
mask-position: center;
animation: checkmarkOut 80ms cubic-bezier(0.65, 0, 0.35, 1); /* forwards; slightly snappier animation out */
animation: checkmarkOut 80ms cubic-bezier(0.65, 0, 0.35, 1);
/* forwards; slightly snappier animation out */
}

@media (forced-colors: active) {
Expand All @@ -400,7 +414,8 @@
border-color: var(--control-checked-borderColor-rest);
transition:
background-color,
border-color 80ms cubic-bezier(0.32, 0, 0.67, 0) 0ms; /* unchecked -> checked */
border-color 80ms cubic-bezier(0.32, 0, 0.67, 0) 0ms;
/* unchecked -> checked */

&::before {
visibility: visible;
Expand Down Expand Up @@ -623,7 +638,8 @@ span wrapping svg or text */
min-width: max-content;
min-height: var(--control-medium-lineBoxHeight);
/* stylelint-disable-next-line primer/typography */
line-height: 20px; /* temporary until we fix line-height rounding in primitives */
line-height: 20px;
/* temporary until we fix line-height rounding in primitives */
color: var(--fgColor-muted);
pointer-events: none;
fill: var(--fgColor-muted);
Expand All @@ -636,7 +652,8 @@ span wrapping svg or text */
font-size: var(--text-body-size-medium);
font-weight: var(--base-text-weight-normal);
/* stylelint-disable-next-line primer/typography */
line-height: 20px; /* temporary until we fix line-height rounding in primitives */
line-height: 20px;
/* temporary until we fix line-height rounding in primitives */
color: var(--fgColor-default);
grid-area: label;
/* stylelint-disable-next-line declaration-property-value-keyword-no-deprecated */
Expand All @@ -658,6 +675,24 @@ span wrapping svg or text */
.TrailingActionButton {
border-top-left-radius: 0;
border-bottom-left-radius: 0;

/* Preserve width consistency when loading state is active for text buttons only */
&[data-loading='true']:has([data-component='buttonContent']) {
/* Double the left padding to compensate for missing right padding */
padding: 0 0 0 calc(var(--base-size-12) * 2);

/* Position spinner at the end to align with IconButton */
& [data-component='loadingSpinner'] {
place-self: end;
/* Match the IconButton spinner size */
width: var(--control-medium-size, 2rem);
height: var(--control-medium-size, 2rem);
/* Ensure spinner is properly centered */
display: flex;
align-items: center;
justify-content: center;
}
}
}

.InactiveButtonWrap {
Expand Down
12 changes: 11 additions & 1 deletion packages/react/src/ActionList/TrailingAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ type ElementProps =
| {
as?: 'button'
href?: never
/**
* Specify whether the action is in a loading state.
* Only available for button elements.
*/
loading?: boolean
}
| {
as: 'a'
href: string
loading?: never
}

export type ActionListTrailingActionProps = ElementProps & {
Expand All @@ -22,7 +28,7 @@ export type ActionListTrailingActionProps = ElementProps & {
}

export const TrailingAction = forwardRef(
({as = 'button', icon, label, href = null, className, ...props}, forwardedRef) => {
({as = 'button', icon, label, href = null, className, loading, ...props}, forwardedRef) => {
return (
<span className={clsx(className, classes.TrailingAction)}>
{icon ? (
Expand All @@ -33,6 +39,8 @@ export const TrailingAction = forwardRef(
variant="invisible"
tooltipDirection="w"
href={href}
loading={loading}
data-loading={Boolean(loading)}
// @ts-expect-error StyledButton wants both Anchor and Button refs
ref={forwardedRef}
className={classes.TrailingActionButton}
Expand All @@ -44,6 +52,8 @@ export const TrailingAction = forwardRef(
variant="invisible"
as={as}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: I'm wondering if we'd want to convert this to a button when loading state is true (e.g. as={loading ? undefined : as}). Mainly because we can't disable a link (aria-disabled is added to the loading states of IconButton). There are some accessibility considerations, such as if it's confusing to go from a button to a link once the loading state is finished. We'd also need to ensure focus remained on the element once the loading state resolves. An alternative is to only allow loading states for button trailing actions.

I'm indifferent on this though. Curious what you and @joshblack think!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer the type restriction approach - only allowing loading states for button trailing actions. The element type remains consistent, no changing from a button to a link.

href={href}
loading={loading}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed when using loading without an Icon (e.g. ActionList.TrailingAction as="a" href="#" label="Some action 1" loading />) the TrailingAction takes the full width of the inner contents when loading. I'm wondering if we should add some sort of conditional or style so that the width remains consistent when loading is true.

Example:

Trailing action within ActionList, that is currently in a loading state. The action itself has a large width, due to the inner text being only visibly hidden via `visibility: hidden` rather than `display: none`

What we might want:

Trailing Action with a smaller width due to the inner contents being removed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! I've actually addressed this by aligning the spinner to the right when loading, rather than changing the button width, so the button maintains its natural width and the layout doesn't shift during the loading transition. Let me know what you think 👀

data-loading={Boolean(loading)}
ref={forwardedRef}
className={classes.TrailingActionButton}
{...props}
Expand Down