Skip to content

Commit ccd5bab

Browse files
authored
Add loading support to ActionList.TrailingAction component (#6239)
1 parent e11a508 commit ccd5bab

File tree

5 files changed

+159
-65
lines changed

5 files changed

+159
-65
lines changed

.changeset/calm-hoops-tie.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
Add loading support to ActionList.TrailingAction component.

packages/react/src/ActionList/ActionList.docs.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,12 @@
251251
"name": "href",
252252
"type": "string",
253253
"description": "href when the TrailingAction is rendered as a link."
254+
},
255+
{
256+
"name": "loading",
257+
"type": "boolean",
258+
"defaultValue": "false",
259+
"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'."
254260
}
255261
]
256262
},

packages/react/src/ActionList/ActionList.features.stories.tsx

Lines changed: 96 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -839,64 +839,102 @@ export const WithCustomTrailingVisuals = () => (
839839
</ActionList>
840840
)
841841

842-
// removing this until CSS Modules FF ships, currently broken in production if button semantic FF is false
843-
// export const WithTrailingAction = () => {
844-
// return (
845-
// <FeatureFlags flags={{primer_react_action_list_item_as_button: true}}>
846-
// <ActionList>
847-
// <ActionList.Item>
848-
// <ActionList.LeadingVisual>
849-
// <FileDirectoryIcon />
850-
// </ActionList.LeadingVisual>
851-
// Item 1 (with default TrailingAction)
852-
// <ActionList.TrailingAction label="Expand sidebar" icon={ArrowLeftIcon} />
853-
// </ActionList.Item>
854-
// <ActionList.Item>
855-
// Item 2 (with link TrailingAction)
856-
// <ActionList.TrailingAction as="a" href="#" label="Some action 1" icon={ArrowRightIcon} />
857-
// </ActionList.Item>
858-
// <ActionList.Item>
859-
// Item 3<ActionList.Description>This is an inline description.</ActionList.Description>
860-
// <ActionList.TrailingAction label="Some action 2" icon={BookIcon} />
861-
// </ActionList.Item>
862-
// <ActionList.Item>
863-
// Item 4<ActionList.Description variant="block">This is a block description.</ActionList.Description>
864-
// <ActionList.TrailingAction label="Some action 3" icon={BookIcon} />
865-
// </ActionList.Item>
866-
// <ActionList.Item>
867-
// Item 5<ActionList.Description variant="block">This is a block description.</ActionList.Description>
868-
// <ActionList.TrailingAction label="Some action 4" />
869-
// </ActionList.Item>
870-
// <ActionList.Item>
871-
// Item 6
872-
// <ActionList.TrailingAction href="#" as="a" label="Some action 5" />
873-
// </ActionList.Item>
874-
// <ActionList.LinkItem href="#">
875-
// LinkItem 1
876-
// <ActionList.Description>
877-
// with TrailingAction this is a long description and should not cause horizontal scroll on smaller screen
878-
// sizes
879-
// </ActionList.Description>
880-
// <ActionList.TrailingAction label="Another action" />
881-
// </ActionList.LinkItem>
882-
// <ActionList.LinkItem href="#">
883-
// LinkItem 2
884-
// <ActionList.Description>
885-
// with TrailingVisual this is a long description and should not cause horizontal scroll on smaller screen
886-
// sizes
887-
// </ActionList.Description>
888-
// <ActionList.TrailingVisual>
889-
// <TableIcon />
890-
// </ActionList.TrailingVisual>
891-
// </ActionList.LinkItem>
892-
// <ActionList.Item inactiveText="Unavailable due to an outage">
893-
// Inactive Item<ActionList.Description>With TrailingAction</ActionList.Description>
894-
// <ActionList.TrailingAction as="a" href="#" label="Some action 8" icon={ArrowRightIcon} />
895-
// </ActionList.Item>
896-
// </ActionList>
897-
// </FeatureFlags>
898-
// )
899-
// }
842+
export const WithTrailingAction = () => {
843+
const [loadingState, setLoadingState] = React.useState(false)
844+
845+
// Auto-toggle every 2.5 seconds to continuously show transitions
846+
React.useEffect(() => {
847+
const interval = setInterval(() => {
848+
setLoadingState(prev => !prev)
849+
}, 2500)
850+
851+
return () => clearInterval(interval)
852+
}, [])
853+
854+
return (
855+
<FeatureFlags flags={{primer_react_action_list_item_as_button: true}}>
856+
<ActionList>
857+
<ActionList.Item>
858+
<ActionList.LeadingVisual>
859+
<FileDirectoryIcon />
860+
</ActionList.LeadingVisual>
861+
Item 1 (with default TrailingAction)
862+
<ActionList.TrailingAction label="Expand sidebar" icon={ArrowLeftIcon} />
863+
</ActionList.Item>
864+
<ActionList.Item>
865+
Item 2 (with link TrailingAction)
866+
<ActionList.TrailingAction as="a" href="#" label="Some action 1" icon={ArrowRightIcon} />
867+
</ActionList.Item>
868+
<ActionList.Item>
869+
Item 3<ActionList.Description>This is an inline description.</ActionList.Description>
870+
<ActionList.TrailingAction label="Some action 2" icon={BookIcon} />
871+
</ActionList.Item>
872+
<ActionList.Item>
873+
Item 4<ActionList.Description variant="block">This is a block description.</ActionList.Description>
874+
<ActionList.TrailingAction label="Some action 3" icon={BookIcon} />
875+
</ActionList.Item>
876+
<ActionList.Item>
877+
Item 5<ActionList.Description variant="block">This is a block description.</ActionList.Description>
878+
<ActionList.TrailingAction label="Some action 4" />
879+
</ActionList.Item>
880+
<ActionList.Item>
881+
Item 6
882+
<ActionList.TrailingAction href="#" as="a" label="Some action 5" />
883+
</ActionList.Item>
884+
<ActionList.Item>
885+
Icon button loading state
886+
<ActionList.Description>
887+
Shows how IconButton maintains width and centers spinner when loading
888+
</ActionList.Description>
889+
<ActionList.TrailingAction label="Process item" icon={ArrowRightIcon} loading />
890+
</ActionList.Item>
891+
<ActionList.Item>
892+
Icon button with transitions
893+
<ActionList.Description>
894+
Automatically toggles loading state every 2.5 seconds to show transitions
895+
</ActionList.Description>
896+
<ActionList.TrailingAction label="Toggle loading" icon={ArrowRightIcon} loading={loadingState} />
897+
</ActionList.Item>
898+
<ActionList.Item>
899+
Text button loading state
900+
<ActionList.Description>
901+
Shows how text button aligns spinner to the right and preserves width
902+
</ActionList.Description>
903+
<ActionList.TrailingAction label="Save changes" loading />
904+
</ActionList.Item>
905+
<ActionList.Item>
906+
Text button with transitions
907+
<ActionList.Description>
908+
Automatically toggles loading state every 2.5 seconds to show transitions
909+
</ActionList.Description>
910+
<ActionList.TrailingAction label="Apply settings" loading={loadingState} />
911+
</ActionList.Item>
912+
<ActionList.LinkItem href="#">
913+
LinkItem 1
914+
<ActionList.Description>
915+
with TrailingAction this is a long description and should not cause horizontal scroll on smaller screen
916+
sizes
917+
</ActionList.Description>
918+
<ActionList.TrailingAction label="Another action" />
919+
</ActionList.LinkItem>
920+
<ActionList.LinkItem href="#">
921+
LinkItem 2
922+
<ActionList.Description>
923+
with TrailingVisual this is a long description and should not cause horizontal scroll on smaller screen
924+
sizes
925+
</ActionList.Description>
926+
<ActionList.TrailingVisual>
927+
<TableIcon />
928+
</ActionList.TrailingVisual>
929+
</ActionList.LinkItem>
930+
<ActionList.Item inactiveText="Unavailable due to an outage">
931+
Inactive Item<ActionList.Description>With TrailingAction</ActionList.Description>
932+
<ActionList.TrailingAction as="a" href="#" label="Some action 8" icon={ArrowRightIcon} />
933+
</ActionList.Item>
934+
</ActionList>
935+
</FeatureFlags>
936+
)
937+
}
900938

901939
export const FullVariant = () => (
902940
<ActionList variant="full">

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

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@
107107
:focus,
108108
&:focus-visible,
109109
/* stylelint-disable-next-line selector-no-qualifying-type */
110-
> a.focus-visible,
110+
>a.focus-visible,
111111
&[data-is-active-descendant] {
112112
/* Support for Windows high contrast https://sarahmhigley.com/writing/whcm-quick-tips */
113113
outline: solid 1px transparent !important;
@@ -342,6 +342,18 @@
342342
}
343343
}
344344

345+
/* When TrailingAction is in loading state, keep labels and descriptions accessible */
346+
&:has(.TrailingAction [data-loading='true']):not([aria-disabled='true']) {
347+
/* Ensure labels and descriptions maintain accessibility contrast */
348+
& .ItemLabel {
349+
color: var(--fgColor-default);
350+
}
351+
352+
& .Description {
353+
color: var(--fgColor-default);
354+
}
355+
}
356+
345357
/* Make sure that the first visible item isn't a divider */
346358
&[aria-hidden] + .Divider {
347359
display: none;
@@ -364,7 +376,8 @@
364376
border-radius: var(--borderRadius-small);
365377
transition:
366378
background-color,
367-
border-color 80ms cubic-bezier(0.33, 1, 0.68, 1); /* checked -> unchecked - add 120ms delay to fully see animation-out */
379+
border-color 80ms cubic-bezier(0.33, 1, 0.68, 1);
380+
/* checked -> unchecked - add 120ms delay to fully see animation-out */
368381

369382
place-content: center;
370383

@@ -382,7 +395,8 @@
382395
mask-size: 75%;
383396
mask-repeat: no-repeat;
384397
mask-position: center;
385-
animation: checkmarkOut 80ms cubic-bezier(0.65, 0, 0.35, 1); /* forwards; slightly snappier animation out */
398+
animation: checkmarkOut 80ms cubic-bezier(0.65, 0, 0.35, 1);
399+
/* forwards; slightly snappier animation out */
386400
}
387401

388402
@media (forced-colors: active) {
@@ -400,7 +414,8 @@
400414
border-color: var(--control-checked-borderColor-rest);
401415
transition:
402416
background-color,
403-
border-color 80ms cubic-bezier(0.32, 0, 0.67, 0) 0ms; /* unchecked -> checked */
417+
border-color 80ms cubic-bezier(0.32, 0, 0.67, 0) 0ms;
418+
/* unchecked -> checked */
404419

405420
&::before {
406421
visibility: visible;
@@ -623,7 +638,8 @@ span wrapping svg or text */
623638
min-width: max-content;
624639
min-height: var(--control-medium-lineBoxHeight);
625640
/* stylelint-disable-next-line primer/typography */
626-
line-height: 20px; /* temporary until we fix line-height rounding in primitives */
641+
line-height: 20px;
642+
/* temporary until we fix line-height rounding in primitives */
627643
color: var(--fgColor-muted);
628644
pointer-events: none;
629645
fill: var(--fgColor-muted);
@@ -636,7 +652,8 @@ span wrapping svg or text */
636652
font-size: var(--text-body-size-medium);
637653
font-weight: var(--base-text-weight-normal);
638654
/* stylelint-disable-next-line primer/typography */
639-
line-height: 20px; /* temporary until we fix line-height rounding in primitives */
655+
line-height: 20px;
656+
/* temporary until we fix line-height rounding in primitives */
640657
color: var(--fgColor-default);
641658
grid-area: label;
642659
/* stylelint-disable-next-line declaration-property-value-keyword-no-deprecated */
@@ -658,6 +675,24 @@ span wrapping svg or text */
658675
.TrailingActionButton {
659676
border-top-left-radius: 0;
660677
border-bottom-left-radius: 0;
678+
679+
/* Preserve width consistency when loading state is active for text buttons only */
680+
&[data-loading='true']:has([data-component='buttonContent']) {
681+
/* Double the left padding to compensate for missing right padding */
682+
padding: 0 0 0 calc(var(--base-size-12) * 2);
683+
684+
/* Position spinner at the end to align with IconButton */
685+
& [data-component='loadingSpinner'] {
686+
place-self: end;
687+
/* Match the IconButton spinner size */
688+
width: var(--control-medium-size, 2rem);
689+
height: var(--control-medium-size, 2rem);
690+
/* Ensure spinner is properly centered */
691+
display: flex;
692+
align-items: center;
693+
justify-content: center;
694+
}
695+
}
661696
}
662697

663698
.InactiveButtonWrap {

packages/react/src/ActionList/TrailingAction.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@ type ElementProps =
99
| {
1010
as?: 'button'
1111
href?: never
12+
/**
13+
* Specify whether the action is in a loading state.
14+
* Only available for button elements.
15+
*/
16+
loading?: boolean
1217
}
1318
| {
1419
as: 'a'
1520
href: string
21+
loading?: never
1622
}
1723

1824
export type ActionListTrailingActionProps = ElementProps & {
@@ -22,7 +28,7 @@ export type ActionListTrailingActionProps = ElementProps & {
2228
}
2329

2430
export const TrailingAction = forwardRef(
25-
({as = 'button', icon, label, href = null, className, ...props}, forwardedRef) => {
31+
({as = 'button', icon, label, href = null, className, loading, ...props}, forwardedRef) => {
2632
return (
2733
<span className={clsx(className, classes.TrailingAction)}>
2834
{icon ? (
@@ -33,6 +39,8 @@ export const TrailingAction = forwardRef(
3339
variant="invisible"
3440
tooltipDirection="w"
3541
href={href}
42+
loading={loading}
43+
data-loading={Boolean(loading)}
3644
// @ts-expect-error StyledButton wants both Anchor and Button refs
3745
ref={forwardedRef}
3846
className={classes.TrailingActionButton}
@@ -44,6 +52,8 @@ export const TrailingAction = forwardRef(
4452
variant="invisible"
4553
as={as}
4654
href={href}
55+
loading={loading}
56+
data-loading={Boolean(loading)}
4757
ref={forwardedRef}
4858
className={classes.TrailingActionButton}
4959
{...props}

0 commit comments

Comments
 (0)