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}