Skip to content
Draft
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
3 changes: 1 addition & 2 deletions src/components/ButtonComposed/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,8 @@ function Button({
shouldRemoveBorderRadius ? borderRadiusStyles[shouldRemoveBorderRadius] : undefined,
styles.alignItemsStretch,
innerStyles,
variant === 'link' && styles.bgTransparent,
],
[styles, StyleUtils, size, horizontalPaddingBySize, buttonVariantStyles, shouldRemoveBorderRadius, borderRadiusStyles, innerStyles, variant],
[styles, StyleUtils, size, horizontalPaddingBySize, buttonVariantStyles, shouldRemoveBorderRadius, borderRadiusStyles, innerStyles],
);

const buttonContainerStyles = useMemo<StyleProp<ViewStyle>>(
Expand Down
93 changes: 93 additions & 0 deletions src/components/ButtonComposed/composed/LinkButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React from 'react';
import Button from '@components/ButtonComposed/Button';
import ButtonDoubleLineText from '@components/ButtonComposed/primitives/ButtonDoubleLineText';
import ButtonIcon from '@components/ButtonComposed/primitives/ButtonIcon';
import ButtonKeyboardShortcut from '@components/ButtonComposed/primitives/ButtonKeyboardShortcut';
import type {ButtonTextProps} from '@components/ButtonComposed/primitives/ButtonText';
import ButtonText from '@components/ButtonComposed/primitives/ButtonText';
import type {ButtonProps} from '@components/ButtonComposed/types';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

/**
* Link-styled text primitive used inside `LinkButton`. Wraps `ButtonText` and
* applies the link-specific typography (normal font weight, label font size)
* plus the link color, swapping to `theme.linkHover` while the button is
* hovered. Reads `isHovered` from the parent `Button`'s context so the hover
* state is in sync with the surrounding pressable.
*/
function LinkButtonText({children, numberOfLines, style, hoverStyle}: ButtonTextProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();

return (
<ButtonText
numberOfLines={numberOfLines}
style={[styles.fontWeightNormal, styles.fontSizeLabel, style, styles.link]}
hoverStyle={[StyleUtils.getColorStyle(theme.linkHover), hoverStyle]}
>
{children}
</ButtonText>
);
}

/**
* LinkButton – composable link-style button.
*
* Drop-in replacement for `<Button link>` from the legacy `@components/Button`.
* Built on top of the new `ButtonComposed` `Button`, but `Button` no longer
* exposes a `'link'` variant — all link-specific behavior lives here:
* - Transparent background applied as an invariant (callers cannot override
* it via `innerStyles`).
* - `shouldUseDefaultHover` is forced off because the legacy `link` Button
* was always rendered with `shouldUseDefaultHover={false}` everywhere it
* was used in the codebase. The two props are coupled in practice, so we
* bake that coupling into `LinkButton` and remove both from the public API.
* - `LinkButton.Text` (a `LinkButtonText` instance) applies link-colored
* typography.
*
* The name is `LinkButton` (not `Link`) because `Link` collides with the
* `jsx-a11y/anchor-is-valid` rule that treats `<Link>` as an HTML anchor and
* demands an `href`. Our component is a button, never an anchor, so we use a
* name that doesn't trigger that false positive.
*
* Like `Button`, content is composed via children using the same primitives:
* - `LinkButton.Icon`
* - `LinkButton.Text`
* - `LinkButton.DoubleLineText`
* - `LinkButton.KeyboardShortcut`
*
* @example
* ```tsx
* <LinkButton onPress={handlePress}>
* <LinkButton.Icon src={icons.ExternalLink} />
* <LinkButton.Text>Open docs</LinkButton.Text>
* </LinkButton>
* ```
*/
type LinkButtonProps = Omit<ButtonProps, 'variant' | 'shouldUseDefaultHover'>;

function LinkButtonComponent({innerStyles = [], children, ...rest}: LinkButtonProps) {
const styles = useThemeStyles();
return (
<Button
{...rest}
innerStyles={[innerStyles, styles.bgTransparent]}
shouldUseDefaultHover={false}
>
{children}
</Button>
);
}

const LinkButton = Object.assign(LinkButtonComponent, {
Icon: ButtonIcon,
Text: LinkButtonText,
DoubleLineText: ButtonDoubleLineText,
KeyboardShortcut: ButtonKeyboardShortcut,
});

export default LinkButton;
export type {LinkButtonProps};
8 changes: 1 addition & 7 deletions src/components/ButtonComposed/primitives/ButtonText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import React from 'react';
import type {StyleProp, TextStyle} from 'react-native';
import {useButtonContext} from '@components/ButtonComposed/context';
import Text from '@components/Text';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';

Expand All @@ -23,9 +21,7 @@ type ButtonTextProps = {

function ButtonText({children, numberOfLines = 1, style, hoverStyle}: ButtonTextProps) {
const {variant, size, isHovered} = useButtonContext();
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();

const sizeTextStyles = {
[CONST.BUTTON_SIZE.SMALL]: styles.buttonSmallText,
Expand All @@ -36,7 +32,6 @@ function ButtonText({children, numberOfLines = 1, style, hoverStyle}: ButtonText
const variantTextStyles = {
success: styles.buttonSuccessText,
danger: styles.buttonDangerText,
link: [styles.fontWeightNormal, styles.fontSizeLabel],
};

return (
Expand All @@ -49,10 +44,9 @@ function ButtonText({children, numberOfLines = 1, style, hoverStyle}: ButtonText
styles.flexShrink1,
sizeTextStyles[size],
variant ? variantTextStyles[variant] : undefined,
isHovered && hoverStyle,
styles.ph1,
style,
variant === 'link' && [styles.link, isHovered && StyleUtils.getColorStyle(theme.linkHover)],
isHovered && hoverStyle,
]}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
>
Expand Down
2 changes: 0 additions & 2 deletions src/styles/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,12 +627,10 @@ function getButtonVariantStyles(styles: ThemeStyles): ButtonVariantStyles {
normal: {
success: styles.buttonSuccess,
danger: styles.buttonDanger,
link: {},
},
disabled: {
success: [styles.buttonOpacityDisabled],
danger: [styles.buttonOpacityDisabled],
link: [styles.buttonOpacityDisabled, styles.buttonDisabled],
},
};
}
Expand Down
2 changes: 1 addition & 1 deletion src/styles/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ type AvatarStyle = Dimensions & {

type ButtonSizeValue = ValueOf<typeof CONST.DROPDOWN_BUTTON_SIZE>;
type ButtonStateName = ValueOf<typeof CONST.BUTTON_STATES>;
type ButtonVariant = 'success' | 'danger' | 'link';
type ButtonVariant = 'success' | 'danger';
type ButtonVariantStyles = {
normal: Record<ButtonVariant, StyleProp<ViewStyle>>;
disabled: Record<ButtonVariant, StyleProp<ViewStyle>>;
Expand Down
10 changes: 4 additions & 6 deletions tests/ui/components/ComposedButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ describe('ButtonComposed — Button', () => {
expect(screen.getByTestId('ctx-isHovered')).toHaveTextContent('false');
});

it.each(['success', 'danger', 'link'] as const)('propagates variant="%s" to children via context', (variant) => {
it.each(['success', 'danger'] as const)('propagates variant="%s" to children via context', (variant) => {
renderButton({variant});
expect(screen.getByTestId('ctx-variant')).toHaveTextContent(variant);
});
Expand Down Expand Up @@ -280,11 +280,9 @@ describe('ButtonComposed — Button', () => {
expect(screen.getByLabelText(LABEL)).toHaveStyle({backgroundColor: expectedBg});
});

it('variant="link" sets a transparent background', () => {
// Link buttons must not obscure underlying content.
renderButton({variant: 'link'});
expect(screen.getByLabelText(LABEL)).toHaveStyle({backgroundColor: 'transparent'});
});
// The transparent-background invariant for link-style buttons now lives in the
// Link component (see tests/ui/components/Link.tsx) — Button itself no longer
// exposes a 'link' variant.

it('no variant uses the default button background', () => {
// Implicit check that nothing accidentally overrides the base background.
Expand Down
90 changes: 90 additions & 0 deletions tests/ui/components/LinkButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {fireEvent, render, screen} from '@testing-library/react-native';
import React from 'react';
import LinkButton from '@src/components/ButtonComposed/composed/LinkButton';
import ButtonDoubleLineText from '@src/components/ButtonComposed/primitives/ButtonDoubleLineText';
import ButtonIcon from '@src/components/ButtonComposed/primitives/ButtonIcon';
import ButtonKeyboardShortcut from '@src/components/ButtonComposed/primitives/ButtonKeyboardShortcut';
import CONST from '@src/CONST';
import colors from '@src/styles/theme/colors';
import variables from '@src/styles/variables';

const LABEL = 'test-link';
const TEXT = 'Open docs';

const renderLinkButton = (innerStyles?: React.ComponentProps<typeof LinkButton>['innerStyles']) =>
render(
<LinkButton
accessibilityLabel={LABEL}
innerStyles={innerStyles}
>
<LinkButton.Text>{TEXT}</LinkButton.Text>
</LinkButton>,
);

const getButton = () => screen.getByRole(CONST.ROLE.BUTTON, {name: LABEL});

describe('ButtonComposed — LinkButton', () => {
describe('transparent background invariant', () => {
it('renders with a transparent background by default', () => {
// Link buttons must not obscure underlying content.
renderLinkButton();
expect(getButton()).toHaveStyle({backgroundColor: 'transparent'});
});

it('keeps the transparent background even when innerStyles tries to override it', () => {
// bgTransparent is appended AFTER innerStyles inside LinkButton, so callers cannot
// accidentally re-introduce a background and break the link visual.
renderLinkButton({backgroundColor: 'red'});
expect(getButton()).toHaveStyle({backgroundColor: 'transparent'});
});
});

describe('shouldUseDefaultHover invariant', () => {
it('does not apply the default button hover background when hovered', () => {
// LinkButton force-disables shouldUseDefaultHover. On hover, the underlying
// Button must NOT swap to the gray hover bg — the link stays transparent.
renderLinkButton();
fireEvent(getButton(), 'hoverIn');
expect(getButton()).toHaveStyle({backgroundColor: 'transparent'});
});
});

describe('LinkButton.Text styling', () => {
it('renders text with link color, normal font weight, and label font size in the default state', () => {
renderLinkButton();
const text = screen.getByText(TEXT);
// theme.link is blue300 in the default dark theme.
expect(text).toHaveStyle({color: colors.blue300});
expect(text).toHaveStyle({fontWeight: '400'});
expect(text).toHaveStyle({fontSize: variables.fontSizeLabel});
});

it('swaps the text color to linkHover when the button is hovered', () => {
// This guards against regressions in the ButtonText style-array ordering
// (hoverStyle must be applied AFTER style so linkHover overrides the default link color).
renderLinkButton();
fireEvent(getButton(), 'hoverIn');
const text = screen.getByText(TEXT);
// theme.linkHover is blue100 in the default dark theme.
expect(text).toHaveStyle({color: colors.blue100});
});

it('reverts to the link color after the pointer leaves', () => {
renderLinkButton();
fireEvent(getButton(), 'hoverIn');
fireEvent(getButton(), 'hoverOut');
expect(screen.getByText(TEXT)).toHaveStyle({color: colors.blue300});
});
});

describe('composable primitives', () => {
// Smoke check that Object.assign-ed primitives are actually exposed and
// point at the underlying ButtonComposed primitives (no link-specific override
// beyond Text). If a refactor accidentally drops one of these, this fails fast.
it('exposes Icon, DoubleLineText, and KeyboardShortcut from the ButtonComposed primitives', () => {
expect(LinkButton.Icon).toBe(ButtonIcon);
expect(LinkButton.DoubleLineText).toBe(ButtonDoubleLineText);
expect(LinkButton.KeyboardShortcut).toBe(ButtonKeyboardShortcut);
});
});
});
3 changes: 0 additions & 3 deletions tests/unit/ButtonStyleUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ const mockStyles = {
buttonSuccess: {backgroundColor: 'green'},
buttonDanger: {backgroundColor: 'red'},
buttonOpacityDisabled: {opacity: 0.5},
buttonDisabled: {backgroundColor: 'gray'},
} as unknown as ThemeStyles;

const {getButtonSizeStyle, getButtonPaddingStyle, getButtonStyleWithIcon, getButtonVariantStyles} = createStyleUtils(mockTheme, mockStyles);
Expand Down Expand Up @@ -79,15 +78,13 @@ describe('getButtonVariantStyles', () => {
expect(variantStyles.normal).toEqual({
success: mockStyles.buttonSuccess,
danger: mockStyles.buttonDanger,
link: {},
});
});

it('returns correct disabled variant styles', () => {
expect(variantStyles.disabled).toEqual({
success: [mockStyles.buttonOpacityDisabled],
danger: [mockStyles.buttonOpacityDisabled],
link: [mockStyles.buttonOpacityDisabled, mockStyles.buttonDisabled],
});
});
});
Loading