diff --git a/.changeset/calm-hoops-tie.md b/.changeset/calm-hoops-tie.md
new file mode 100644
index 00000000000..79b4bc889f8
--- /dev/null
+++ b/.changeset/calm-hoops-tie.md
@@ -0,0 +1,5 @@
+---
+'@primer/react': minor
+---
+
+Add loading support to ActionList.TrailingAction component.
diff --git a/packages/react/src/ActionList/ActionList.docs.json b/packages/react/src/ActionList/ActionList.docs.json
index 726c3177a18..2824342dad7 100644
--- a/packages/react/src/ActionList/ActionList.docs.json
+++ b/packages/react/src/ActionList/ActionList.docs.json
@@ -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'."
}
]
},
diff --git a/packages/react/src/ActionList/ActionList.features.stories.tsx b/packages/react/src/ActionList/ActionList.features.stories.tsx
index 22cc8fa46b0..3ab65a14fb9 100644
--- a/packages/react/src/ActionList/ActionList.features.stories.tsx
+++ b/packages/react/src/ActionList/ActionList.features.stories.tsx
@@ -839,64 +839,102 @@ export const WithCustomTrailingVisuals = () => (
)
-// removing this until CSS Modules FF ships, currently broken in production if button semantic FF is false
-// export const WithTrailingAction = () => {
-// return (
-//
-//
-//
-//
-//
-//
-// Item 1 (with default TrailingAction)
-//
-//
-//
-// Item 2 (with link TrailingAction)
-//
-//
-//
-// Item 3This is an inline description.
-//
-//
-//
-// Item 4This is a block description.
-//
-//
-//
-// Item 5This is a block description.
-//
-//
-//
-// Item 6
-//
-//
-//
-// LinkItem 1
-//
-// with TrailingAction this is a long description and should not cause horizontal scroll on smaller screen
-// sizes
-//
-//
-//
-//
-// LinkItem 2
-//
-// with TrailingVisual this is a long description and should not cause horizontal scroll on smaller screen
-// sizes
-//
-//
-//
-//
-//
-//
-// Inactive ItemWith TrailingAction
-//
-//
-//
-//
-// )
-// }
+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 (
+
+
+
+
+
+
+ Item 1 (with default TrailingAction)
+
+
+
+ Item 2 (with link TrailingAction)
+
+
+
+ Item 3This is an inline description.
+
+
+
+ Item 4This is a block description.
+
+
+
+ Item 5This is a block description.
+
+
+
+ Item 6
+
+
+
+ Icon button loading state
+
+ Shows how IconButton maintains width and centers spinner when loading
+
+
+
+
+ Icon button with transitions
+
+ Automatically toggles loading state every 2.5 seconds to show transitions
+
+
+
+
+ Text button loading state
+
+ Shows how text button aligns spinner to the right and preserves width
+
+
+
+
+ Text button with transitions
+
+ Automatically toggles loading state every 2.5 seconds to show transitions
+
+
+
+
+ LinkItem 1
+
+ with TrailingAction this is a long description and should not cause horizontal scroll on smaller screen
+ sizes
+
+
+
+
+ LinkItem 2
+
+ with TrailingVisual this is a long description and should not cause horizontal scroll on smaller screen
+ sizes
+
+
+
+
+
+
+ Inactive ItemWith TrailingAction
+
+
+
+
+ )
+}
export const FullVariant = () => (
diff --git a/packages/react/src/ActionList/ActionList.module.css b/packages/react/src/ActionList/ActionList.module.css
index 9a9cb4806a8..376a96b7844 100644
--- a/packages/react/src/ActionList/ActionList.module.css
+++ b/packages/react/src/ActionList/ActionList.module.css
@@ -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;
@@ -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;
@@ -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;
@@ -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) {
@@ -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;
@@ -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);
@@ -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 */
@@ -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 {
diff --git a/packages/react/src/ActionList/TrailingAction.tsx b/packages/react/src/ActionList/TrailingAction.tsx
index 4b17748e5a6..d974bd0ab9a 100644
--- a/packages/react/src/ActionList/TrailingAction.tsx
+++ b/packages/react/src/ActionList/TrailingAction.tsx
@@ -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 & {
@@ -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 (
{icon ? (
@@ -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}
@@ -44,6 +52,8 @@ export const TrailingAction = forwardRef(
variant="invisible"
as={as}
href={href}
+ loading={loading}
+ data-loading={Boolean(loading)}
ref={forwardedRef}
className={classes.TrailingActionButton}
{...props}