Skip to content
Merged
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
10 changes: 10 additions & 0 deletions config/eslint/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ const localRulesDir = path.resolve(projectRoot, 'eslint-plugin-local-rules');
rulesdir.RULES_DIR = [expensifyRulesDir, localRulesDir];

const restrictedImportPaths = [
{
name: '@components/Button',
importNames: ['default'],
Comment thread
Guccio163 marked this conversation as resolved.
message: 'The legacy Button is deprecated. Please use the composed Button from `@components/ButtonComposed` instead. Importing the `ButtonProps` type from here is still allowed.',
},
{
name: '@src/components/Button',
importNames: ['default'],
message: 'The legacy Button is deprecated. Please use the composed Button from `@components/ButtonComposed` instead. Importing the `ButtonProps` type from here is still allowed.',
},
{
name: 'react-native',
importNames: [
Expand Down
287 changes: 281 additions & 6 deletions config/eslint/eslint.seatbelt.tsv

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/components/ButtonComposed/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,11 @@ function Button({
isHovered,
variant,
size,
onPress,
isDisabled,
isLoading,
}),
[isHovered, variant, size],
[isHovered, variant, size, onPress, isDisabled, isLoading],
);

const buttonVariantStyles = useMemo(() => {
Expand Down
6 changes: 2 additions & 4 deletions src/components/ButtonComposed/composed/ButtonWithIcons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import React from 'react';

type ButtonWithIconsProps = BaseButtonProps &
ButtonKeyboardShortcutProps & {
/** Whether pressing Enter triggers onPress — when true, the Enter-key shortcut is mounted. */
pressOnEnter?: boolean;
// Icon Left Props
iconLeft?: IconAsset;
iconLeftFill?: string;
Expand Down Expand Up @@ -108,13 +110,9 @@ function ButtonWithIcons({
>
{!!pressOnEnter && (
<ButtonKeyboardShortcut
pressOnEnter={pressOnEnter}
allowBubble={allowBubble}
enterKeyEventListenerPriority={enterKeyEventListenerPriority}
isPressOnEnterActive={isPressOnEnterActive}
isDisabled={isDisabled}
isLoading={isLoading}
onPress={onPress}
/>
)}
{!!iconLeft && (
Expand Down
3 changes: 3 additions & 0 deletions src/components/ButtonComposed/context/ButtonContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ const defaultButtonContextValue: ButtonContextValue = {
isHovered: false,
variant: undefined,
size: CONST.BUTTON_SIZE.MEDIUM,
onPress: () => {},
isDisabled: false,
isLoading: false,
};

const ButtonContext = createContext<ButtonContextValue>(defaultButtonContextValue);
Expand Down
12 changes: 11 additions & 1 deletion src/components/ButtonComposed/context/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import type {ButtonVariant} from '@styles/utils/types';

import type CONST from '@src/CONST';

import type {GestureResponderEvent} from 'react-native';
import type {ValueOf} from 'type-fest';

/** Values published by the parent `Button` for its child primitives (Text/Icon/...) to consume via `useButtonContext`. */
/** Values published by the parent `Button` for its child primitives (Text/Icon/KeyboardShortcut/...) to consume via `useButtonContext`. */
type ButtonContextValue = {
/** Button size — primitives use it to pick matching paddings/icon dimensions/font sizes. */
size: ValueOf<typeof CONST.BUTTON_SIZE>;
Expand All @@ -14,6 +15,15 @@ type ButtonContextValue = {

/** True while the cursor is over the Button — primitives swap to hover-state colors/styles when set. */
isHovered: boolean;

/** The Button's press handler — `ButtonKeyboardShortcut` fires it when Enter is pressed. */
onPress: (event?: GestureResponderEvent | KeyboardEvent) => void | Promise<void>;

/** Whether the Button is disabled — `ButtonKeyboardShortcut` uses it to block the Enter shortcut. */
isDisabled: boolean;

/** Whether the Button is loading — `ButtonKeyboardShortcut` uses it to block the Enter shortcut. */
isLoading: boolean;
};

export type {ButtonContextValue, ButtonVariant};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import validateSubmitShortcut from '@components/Button/validateSubmitShortcut';
import {useButtonContext} from '@components/ButtonComposed/context';
import type {ButtonKeyboardShortcutProps} from '@components/ButtonComposed/types';

import useActiveElementRole from '@hooks/useActiveElementRole';
Expand All @@ -14,25 +15,20 @@ import {useCallback, useMemo} from 'react';
const accessibilityRoles: string[] = Object.values(CONST.ROLE);

/**
* Registers an Enter-key keyboard shortcut that triggers the button's onPress handler.
* Registers an Enter-key keyboard shortcut that triggers the parent Button's onPress handler.
* Renders nothing to the DOM — it is a pure-behavior primitive intended to be composed
* alongside ButtonText, ButtonIconLeft, etc. as a child of Button.
*
* Usage:
* <Button onPress={submit}>
* <ButtonKeyboardShortcut pressOnEnter onPress={submit} isDisabled={isDisabled} />
* <Button onPress={submit} isDisabled={isDisabled}>
* <ButtonKeyboardShortcut />
* <ButtonText>Submit</ButtonText>
* </Button>
*/
function ButtonKeyboardShortcut({
isDisabled = false,
isLoading = false,
onPress = () => {},
pressOnEnter,
allowBubble,
enterKeyEventListenerPriority,
isPressOnEnterActive = false,
}: ButtonKeyboardShortcutProps) {
function ButtonKeyboardShortcut({allowBubble, enterKeyEventListenerPriority, isPressOnEnterActive = false}: ButtonKeyboardShortcutProps) {
// The press handler and disabled/loading state come from the parent Button context.
const {onPress, isDisabled, isLoading} = useButtonContext();
Comment thread
Guccio163 marked this conversation as resolved.

const isFocused = useIsFocused();
const activeElementRole = useActiveElementRole();

Expand All @@ -50,12 +46,12 @@ function ButtonKeyboardShortcut({

const config = useMemo(
() => ({
isActive: pressOnEnter && !shouldDisableEnterShortcut && (isFocused || isPressOnEnterActive),
isActive: !shouldDisableEnterShortcut && (isFocused || isPressOnEnterActive),
shouldBubble: allowBubble,
priority: enterKeyEventListenerPriority,
shouldPreventDefault: false,
}),
[pressOnEnter, shouldDisableEnterShortcut, isFocused, isPressOnEnterActive, allowBubble, enterKeyEventListenerPriority],
[shouldDisableEnterShortcut, isFocused, isPressOnEnterActive, allowBubble, enterKeyEventListenerPriority],
);

useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, keyboardShortcutCallback, config);
Expand Down
16 changes: 1 addition & 15 deletions src/components/ButtonComposed/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,9 @@ type ButtonBehaviorProps = {
};

/**
* Props for the ButtonKeyboardShortcut primitive.
* Groups all Enter-key shortcut configuration in one place so the parent Button
* does not need to know about keyboard-shortcut internals.
* Props for the ButtonKeyboardShortcut primitive — the Enter-key shortcut configuration.
*/
type ButtonKeyboardShortcutProps = {
/** Call the onPress function when Enter key is pressed */
pressOnEnter?: boolean;

/** The priority to assign the enter key event listener. 0 is the highest priority. */
enterKeyEventListenerPriority?: number;

Expand All @@ -71,15 +66,6 @@ type ButtonKeyboardShortcutProps = {

/** Should the press event bubble across multiple instances when Enter key triggers it. */
allowBubble?: boolean;

/** Whether the button is disabled — used by validateSubmitShortcut to block the callback */
isDisabled?: boolean;

/** Whether the button is loading — used by validateSubmitShortcut to block the callback */
isLoading?: boolean;

/** The callback to fire when Enter is pressed and the shortcut is active */
onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void | Promise<void>;
};

type ButtonStyleProps = {
Expand Down
70 changes: 32 additions & 38 deletions tests/ui/components/ButtonKeyboardShortcut.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {render} from '@testing-library/react-native';

import Button from '@src/components/ButtonComposed/Button';
import ButtonKeyboardShortcut from '@src/components/ButtonComposed/primitives/ButtonKeyboardShortcut';
import type {ButtonKeyboardShortcutProps} from '@src/components/ButtonComposed/types';
import type {ButtonKeyboardShortcutProps, ButtonProps} from '@src/components/ButtonComposed/types';

import {NavigationContainer} from '@react-navigation/native';
import React from 'react';
Expand Down Expand Up @@ -29,19 +30,24 @@ jest.mock('@hooks/useKeyboardShortcut', () =>
// ──────────────────────────────────────────────────────────────────────────────

/**
* Renders ButtonKeyboardShortcut inside a NavigationContainer.
* Renders ButtonKeyboardShortcut inside a real Button (which owns ButtonContext) and a NavigationContainer.
* NavigationContainer is required because the component calls useIsFocused().
* isPressOnEnterActive=true is the default here so the shortcut is active
* even without a focused screen, keeping individual tests simple.
* `buttonProps` set the parent Button's onPress/isDisabled/isLoading, which the primitive reads from context.
*/
const renderShortcut = (props: Partial<ButtonKeyboardShortcutProps> = {}) =>
const renderShortcut = (props: Partial<ButtonKeyboardShortcutProps> = {}, buttonProps: Omit<Partial<ButtonProps>, 'children'> = {}) =>
render(
<NavigationContainer>
<ButtonKeyboardShortcut
pressOnEnter
isPressOnEnterActive
{...props}
/>
<Button
accessibilityLabel="button"
{...buttonProps}
>
<ButtonKeyboardShortcut
isPressOnEnterActive
{...props}
/>
</Button>
</NavigationContainer>,
);

Expand All @@ -58,38 +64,22 @@ describe('ButtonKeyboardShortcut', () => {
// ── Registration ───────────────────────────────────────────────────────────

describe('registration', () => {
it('registers an Enter listener when pressOnEnter is true', () => {
// Given a ButtonKeyboardShortcut with pressOnEnter enabled
renderShortcut({pressOnEnter: true});
it('registers an Enter listener when mounted and active', () => {
// Given a mounted ButtonKeyboardShortcut (active via isPressOnEnterActive)
renderShortcut();

// Then useKeyboardShortcut was called and the callback was captured
expect(enterKeyCallback).toBeDefined();
});

it('does not register an Enter listener when pressOnEnter is false', () => {
// Given a ButtonKeyboardShortcut with pressOnEnter disabled
renderShortcut({pressOnEnter: false});

// Then no callback was registered (mock guards on config.isActive)
expect(enterKeyCallback).toBeUndefined();
});

it('does not register an Enter listener when pressOnEnter is omitted', () => {
// Given a ButtonKeyboardShortcut without pressOnEnter
renderShortcut({pressOnEnter: undefined});

// Then no callback was registered
expect(enterKeyCallback).toBeUndefined();
});
});

// ── onPress invocation ─────────────────────────────────────────────────────

describe('onPress invocation', () => {
it('calls onPress when Enter is triggered and the button is enabled', () => {
// Given an active shortcut with an onPress handler
it('calls the context onPress when Enter is triggered and the button is enabled', () => {
// Given an active shortcut whose parent Button provides an onPress handler
const onPress = jest.fn();
renderShortcut({onPress});
renderShortcut({}, {onPress});

// When the Enter key fires
enterKeyCallback?.(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}));
Expand All @@ -98,10 +88,10 @@ describe('ButtonKeyboardShortcut', () => {
expect(onPress).toHaveBeenCalledTimes(1);
});

it('blocks onPress when isDisabled is true', () => {
// Given a shortcut where the button is disabled
it('blocks onPress when the context isDisabled is true', () => {
// Given a shortcut whose parent Button is disabled
const onPress = jest.fn();
renderShortcut({onPress, isDisabled: true});
renderShortcut({}, {onPress, isDisabled: true});

// When the Enter key fires
enterKeyCallback?.(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}));
Expand All @@ -110,10 +100,10 @@ describe('ButtonKeyboardShortcut', () => {
expect(onPress).not.toHaveBeenCalled();
});

it('blocks onPress when isLoading is true', () => {
// Given a shortcut where the button is loading
it('blocks onPress when the context isLoading is true', () => {
// Given a shortcut whose parent Button is loading
const onPress = jest.fn();
renderShortcut({onPress, isLoading: true});
renderShortcut({}, {onPress, isLoading: true});

// When the Enter key fires
enterKeyCallback?.(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}));
Expand Down Expand Up @@ -161,8 +151,12 @@ describe('ButtonKeyboardShortcut', () => {
// ── Rendering ──────────────────────────────────────────────────────────────

it('renders nothing to the DOM', () => {
// Given a rendered ButtonKeyboardShortcut
const {toJSON} = renderShortcut();
// Given the primitive rendered in isolation (default context), so no parent Button markup interferes
const {toJSON} = render(
<NavigationContainer>
<ButtonKeyboardShortcut isPressOnEnterActive />
</NavigationContainer>,
);

// Then it contributes no nodes to the render tree
expect(toJSON()).toBeNull();
Expand Down
Loading
Loading