Skip to content

Migrate Button to ButtonComposed (batch 8) - #97089

Open
mikolajpochec wants to merge 17 commits into
Expensify:mainfrom
software-mansion-labs:@mikolajpochec/button-migration-pr8
Open

Migrate Button to ButtonComposed (batch 8)#97089
mikolajpochec wants to merge 17 commits into
Expensify:mainfrom
software-mansion-labs:@mikolajpochec/button-migration-pr8

Conversation

@mikolajpochec

@mikolajpochec mikolajpochec commented Jul 27, 2026

Copy link
Copy Markdown
Member

Explanation of Change

This is batch 8 (PR8 of 9) in the ongoing effort to migrate all direct <Button> usages (import Button from '@components/Button') to the new composed ButtonComposed, allowing the old Button component to eventually be deprecated. This batch covers the "text + icon · plain + icon-only + no-content" shape and includes ~32 files / ~40 button instances.

Some files listed in the migration issue are not included here because they were renamed, removed, or already migrated:

  • src/components/AvatarCropModal/AvatarCropModal.tsx — renamed to AvatarCropView.tsx and already migrated.
  • src/pages/settings/Agents/AgentsListRow.tsx — no longer exists.
  • src/pages/settings/Agents/AgentsPage.tsx — already migrated.

This batch also includes small migrations of Button wrapper components, where direct migration was simpler than adding adapters. This applies to AnimatedSubmitButton, AnimatedSettlementButton, CardSectionButton, and CardDetailsActionButton.

Fixed Issues

$ #95177
PROPOSAL: #83762 (comment)

Tests

For every migrated button, verify that behaviour is unchanged from before the migration:

  1. Label renders correctly and is not clipped or truncated.
  2. Variant/color is correct — success = green, danger = red, default = grey.
  3. Size is correct — SMALL vs MEDIUM vs LARGE height/padding matches the previous look.
  4. Icon renders on the correct side (left icon before the label, right icon after it) and is centered/grouped with the label.
  5. Disabled state — the button greys out and is non-interactive when its precondition isn't met (offline, throttled).
  6. Loading state — the spinner replaces the label and the button is non-interactive while a request is in flight (isLoading).
  7. Press action — tapping fires the same documented action as before (navigates / submits / opens modal / toggles).

Moreover, I divided the migrated buttons into several categories, based on their shape and rendering behaviour, and recorded one representative for each. For each category, detailed test steps are described in the sections below:

  • Plain left-icon + text button (baseline: an icon sits immediately left of the label, grouped and centered)
  • Icon-only button (no Button.Text to hold the content row open — the lone icon must still center)
  • Raw <Text> child instead of <Button.Text> (the child keeps its own styling — highest-risk visual check)
  • No-content wrapper that forwards a .Text child into the composed Button
  • Dynamic variant mapped from booleans via a helper (success / danger / neutral branches)

For each of these categories, detailed test steps are described in sections below:

Plain left-icon + text button
Preconditions
  1. Be signed in on an account that has at least one workspace (and workspace creation isn't restricted).
Test steps
  1. Open the Account menu (your avatar) → Workspaces.
  2. Look at the green "New" button at the top-right of the workspaces list.
Expected behavior

The Plus icon sits immediately left of the "New" label — icon + text grouped and centered — as a green
success button, identical to a production build. Pressing it opens the new-workspace flow.


Icon-only button (attachment carousel arrows)
Preconditions

None — any chat works (your self-DM is fine).

Test steps
  1. In a chat, attach and send two images (+ / paperclip → Add attachment, or drag-drop on web) so the
    carousel has more than one page.
  2. Tap one of the images — the attachment lightbox opens.
  3. Reveal the arrows: on web desktop, hover the mouse over the carousel (arrows are hidden until hover on
    non-touch); on native / touch they show immediately then auto-hide after ~3s (tap to bring them back).
  4. Confirm the left Back arrow and right forward arrow render against the carousel edges and navigate
    between the two images.
Expected behavior

Each arrow is a small round button with a single centered icon tinted theme.text — no clipping or off-center
glyph — visually identical to production; the tooltips read "Previous" / "Next" and tapping navigates.


Raw <Text> child button (reveal/hide flagged message)

This button passes a bare <Text> as its child rather than <Button.Text>, so the check is that the label's
font size, weight, color, horizontal padding and centering are pixel-identical to production.

Test steps — real path (needs a second account)
  1. From account B, post a normal comment in a chat shared with your account A.
  2. As A, long-press (native) / hover → menu (web) on B's message → Flag as offensive → pick a
    severity that results in hidden message. (You cannot flag your own message, which is why the second account is required.)
  3. Once the message collapses, the small "Reveal message" button appears beneath it; tap it to toggle to
    "Hide message".
Expected behavior

The button label renders identical to production in both toggle states — same small-button text style, padding
and centering as before the migration; no shift in color/weight/size.


No-content wrapper forwarding a .Text child (Subscription payment buttons)

The "Retry payment" / "Authenticate payment" buttons render through the CardSectionButton wrapper,
which forwards its .Text child into the composed Button. This is web-onlyCardSectionButton has a
return null native no-op (pre-existing), so verify on desktop/mobile web.

⚠️ These buttons cannot be reached from the UI alone. They only render either (A) by temporarily
altering code, or (B) from a real backend state (a billing failure / Stripe 3DS challenge). The two
paths are separated below — path A is the recommended, reliable one for this rendering check.

Preconditions
  • Be signed in as the owner of a paid workspace (Collect/Control) — the Subscription page is hidden otherwise.
  • Test on desktop or mobile web (the button is a return null no-op on native).
Path A — surface the button by altering code (recommended)
  1. Open the Account menu (your avatar) → Subscription (or deep-link settings/subscription).
  2. Scroll to the "Payment" section (this section is CardSection).
  3. In src/pages/settings/Subscription/CardSection/CardSection.tsx, stub
    const billingStatus = {isRetryAvailable: true}; just above the return — this forces the "Retry payment"
    guard (billingStatus?.isRetryAvailable !== undefined, ~line 250).
  4. Save — Fast Refresh reloads the page; the large "Retry payment" button now appears in the Payment section.
  5. Verify its layout: label centered, full-width, size=LARGE.
  6. Toggle the network offline — the button greys out / is non-interactive — then back online.
  7. Revert the code change before committing.
Path B — reach the button from a real backend state (no code changes)
  1. Open the Account menu (your avatar) → Subscription (or deep-link settings/subscription).
  2. Scroll to the "Payment" section.
  3. On an account with a real billing failure the "Retry payment" button renders; on a Stripe
    3DS authentication_required state the "Authenticate payment" button renders. Neither state can be
    triggered from the app UI — this path depends on the actual backend condition.
  4. Verify the button's layout and offline behavior as in Path A steps 5–6.
Expected behavior

The CardSectionButton.Text label renders centered inside the full-width size=LARGE button, identical to
production; the disabled (offline) and loading (retry pending) states behave as before.


Floating message counter
Test steps
  1. Open any chat/report with enough history to scroll.
  2. Scroll up, away from the newest message — the floating pill drops into view: green "New messages" when
    genuinely unread messages sit below, otherwise neutral grey "Latest messages".
  3. To force the green state, post to the chat from a second account (or Concierge) while scrolled up.
Expected behavior

Each pill's background color matches its state exactly like production.

Offline tests

QA Steps

Same as tests.

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
button_custom_children_android.mp4
button_icon_left_android
button_only_icon_android.mp4
Android: mWeb Chrome button_cardsection_custom_migration_android_web
button_custom_children_android_web.mp4
button_icon_left_android_web
button_only_icon_android_web.mp4
iOS: Native
button_custom_children_ios.mp4
button_icon_left_ios
button_only_icon_ios.mp4
iOS: mWeb Safari button_cardsection_custom_migration_ios_web
button_custom_children_ios_web.mp4
button_icon_left_ios_web
button_only_icon_ios_web.mp4
MacOS: Chrome / Safari button_cardsection_custom_migration_web button_custom_children_web button_icon_left_web
button_only_icon_web.mp4

@mikolajpochec mikolajpochec changed the title @mikolajpochec/button migration pr8 Migrate Button to ButtonComposed (batch 8) Jul 27, 2026
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Comment on lines 54 to 80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This button keeps raw <Text> children instead of Button.Text, and that silently shrinks it by 8px.

The legacy Button took the children path with text='' and icon=null, so getButtonPaddingStyle saw symmetric icons, returned undefined, and the button kept buttonSmall.paddingHorizontal: 12. ButtonComposed always applies horizontalPaddingBySize[SMALL] = ph2 = 8 (src/components/ButtonComposed/Button.tsx:96). The compensating ph1 lives inside ButtonText (src/components/ButtonComposed/primitives/ButtonText.tsx:51), and this call site never renders ButtonText.

Net effect: 12 -> 8 per side, so the button is 8px narrower than on main. For every other call site in this PR the geometry works out identically, precisely because ButtonText.ph1 makes up the difference.

Comment on lines 50 to 63
<Button
small
size={CONST.BUTTON_SIZE.SMALL}
style={style}
onPress={() => setIsHidden(!isHidden)}
testID="moderationButton"
sentryLabel={CONST.SENTRY_LABEL.ATTACHMENT_CAROUSEL.MODERATION_BUTTON}
>
<Text
style={[styles.buttonSmallText, styles.userSelectNone]}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
>
{isHidden ? translate('moderation.revealMessage') : translate('moderation.hideMessage')}
</Text>
</Button>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same issue as in FloatingMessageCounter: raw <Text> children skip ButtonText, so this button loses the compensating ph1 and ends up 8px narrower (12 -> 8 horizontal padding per side).

Comment on lines 90 to 102

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same issue as in FloatingMessageCounter: raw <Text> children skip ButtonText, so this button loses the compensating ph1 and ends up 8px narrower (12 -> 8 horizontal padding per side).

Comment on lines 63 to 69
<Button
small
size={CONST.BUTTON_SIZE.SMALL}
style={styles.pl2}
onPress={() => removeEvent(reportID, action.reportActionID, event.id, events)}
>
<Text style={styles.buttonSmallText}>{translate('common.remove')}</Text>
</Button>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same issue as in FloatingMessageCounter: raw <Text> children skip ButtonText, so this button loses the compensating ph1 and ends up 8px narrower (12 -> 8 horizontal padding per side).

Comment on lines 21 to 42
@@ -41,14 +41,23 @@ type FloatingPillButtonProps = {
textStyle?: StyleProp<TextStyle>;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With variant in place, success, danger, iconFill and textStyle on FloatingPillButtonProps are redundant.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One caveat: Button.Icon at size=SMALL renders iconSizeExtraSmall (12px, because isButtonIcon is set), while the current raw <Icon size={SMALL}> renders 16px. The icon will shrink, so it needs a visual check.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I got rid of getButtonVariant, but I'm leaving rest of params as they are, since changing Text and Icon to ButtonComposed equivalents breaks UI a bit

onPress={togglePINVisibility}
medium
/>
size={CONST.BUTTON_SIZE.MEDIUM}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MEDIUM is already the default (src/components/ButtonComposed/Button.tsx:28)

medium
text={translate('workspace.common.viewTransactions')}
icon={expensifyIcons.MoneySearch}
size={CONST.BUTTON_SIZE.MEDIUM}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MEDIUM is already the default (src/components/ButtonComposed/Button.tsx:28)

medium
text={translate('workspace.common.viewTransactions')}
icon={expensifyIcons.MoneySearch}
size={CONST.BUTTON_SIZE.MEDIUM}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MEDIUM is already the default (src/components/ButtonComposed/Button.tsx:28)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this is correct, but just to be safe, I'll point out that this file still uses the old Button.

@mikolajpochec mikolajpochec Jul 28, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It seems that src/pages/settings/Wallet/ExpensifyCardPage/index.tsx is a subject of batch 7. I migrated only CardDetailsActionButton in this file due to changes made in its definition in src/pages/settings/Wallet/CardDetailsActionButtons.tsx.

Comment on lines 25 to 26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should probably change this to variant?: typeof CONST.BUTTON_VARIANT.SUCCESS

@dariusz-biela dariusz-biela left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice work, the batch is in good shape.

<Button
size={CONST.BUTTON_SIZE.SMALL}
style={style}
innerStyles={styles.ph3}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could you add a short comment, for example:

// Raw children skip ButtonText and the ph1 it contributes, so the legacy 12px horizontal padding is restored here.
innerStyles={styles.ph3}

<Button
size={CONST.BUTTON_SIZE.SMALL}
style={styles.pl2}
innerStyles={styles.ph3}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as in CarouselItem, please add the one line comment explaining that ph3 replaces the ph1 that ButtonText would normally contribute.

@@ -90,6 +90,7 @@ function ChatMessageContent({action, policyID, reportID, originalReportID, displ
<Button
size={CONST.BUTTON_SIZE.SMALL}
style={[styles.mt2, styles.alignSelfStart]}
innerStyles={styles.ph3}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as in CarouselItem, please add the one line comment explaining that ph3 replaces the ph1 that ButtonText would normally contribute.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the same compensation as in CarouselItem, ChatMessageContent and ChronosOOOListActions, but through a different mechanism. Here it is ph1 on your own View (8 + 4), there it is innerStyles={styles.ph3} (8 overridden by 12). Both land on the legacy 12px, so this is purely about consistency. Could we use one mechanism in all four places? contentContainerStyle={styles.ph1} is the most honest one, because it literally replaces the ph1 that ButtonText would have added, and the prop already exists on ButtonProps. Whichever you pick, the comment from the CarouselItem thread applies here as well.

Comment on lines +24 to +25
/** Inner button variant **/
variant?: ButtonVariant;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NAB: Closes with **/ instead of */

Comment on lines +25 to +26
// Visual variant of the button (only success is supported)
variant?: typeof CONST.BUTTON_VARIANT.SUCCESS;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

// Submit buttons support the default and success styles; danger is not a valid submit state.
  variant?: typeof CONST.BUTTON_VARIANT.SUCCESS;

@@ -50,6 +50,7 @@ function CarouselItem({item, onPress, isFocused, isModalHovered, reportID}: Caro
<Button

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

On main the raw Text was a child of a column container, so a long label wrapped. ButtonComposed wraps children in a flexRow and row items default to flexShrink: 0, so it now overflows instead. Button.Text sets flexShrink1 for this reason. Low risk with these short labels, but worth a check in de and a styles.flexShrink1 if it overflows. Same in ChatMessageContent.tsx:96 and ChronosOOOListActions.tsx:69.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks! I tested all three buttons in German and with an artificially long label and saw no overflow, so I decided I'll leave it as it is

@mikolajpochec
mikolajpochec marked this pull request as ready for review July 31, 2026 11:21
@mikolajpochec
mikolajpochec requested review from a team as code owners July 31, 2026 11:21
@melvin-bot
melvin-bot Bot requested review from ZhenjaHorbach and removed request for a team July 31, 2026 11:21
@melvin-bot

melvin-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

@ZhenjaHorbach Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@melvin-bot
melvin-bot Bot requested review from flaviadefaria and removed request for a team July 31, 2026 11:21
Comment thread src/components/AnimatedSubmitButton/index.tsx Outdated

@Guccio163 Guccio163 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Generally looks good to me, I left some comments and remember to apply Darek's ones

size={CONST.BUTTON_SIZE.SMALL}
onPress={onPress}
sentryLabel={CONST.SENTRY_LABEL.REPORT.FLOATING_MESSAGE_COUNTER}
innerStyles={styles.ph1}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could use a comment like in CarouselItem.tsx, ChronosOOOListActions.tsx, ChatMessageContent.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

BTW I feel like it should be styles.pr3: Firstly we need to compensate with extra padding only on text's side (that's why right instead of horizontal) and Secondly original padding that we aim to achieve is 12px, so pr3, instead of pr1.

/>
stayNormalOnDisable
>
{/* Transparent loading content still affects layout. Mount the icon only after loading so it does not widen the button. */}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unnecessary comment in my opinion. Text's state was previously directly dependent only on showLoading, now it's also on isAnimationRunning. I'd rename isShowingSubmittedState to shouldShowIcon or even move it inline since icon's only place that uses both isAnimationRunning and showLoading directly

Comment on lines +61 to +64
<Button.Icon
src={icons.BackArrow}
fill={theme.text}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice find, we should look into it 👀

<Button
small
size={CONST.BUTTON_SIZE.SMALL}
innerStyles={[styles.arrowIcon]}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NIT:

Suggested change
innerStyles={[styles.arrowIcon]}
innerStyles={styles.arrowIcon}

Comment on lines +154 to +158
<Button.Icon
src={icons.ArrowRight}
fill={theme.white}
/>
</Button>

@Guccio163 Guccio163 Aug 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as in previous comment with icon hover fill

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants