Skip to content

Commit 171688b

Browse files
feat(ui-react): add ButtonTrigger component (#525)
* feat(ui-react): add SelectTriggerButton component * feat(ui-react): rename SelectTriggerButton to ButtonTrigger * fix linting issues * feat(ui-react): deactivate asChild control * fix(ui-react): update button styles to include body-1-semi-bold for medium and large sizes * feat(ui-react): updated props types
1 parent 5a2682a commit 171688b

File tree

10 files changed

+458
-4
lines changed

10 files changed

+458
-4
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@ledgerhq/lumen-ui-react': patch
3+
---
4+
5+
feat: Introduce `ButtonTrigger` component
6+

libs/ui-react/src/lib/Components/Button/BaseButton.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { IconSize } from '../Icon/types';
55
import { Spinner } from '../Spinner';
66
import { BaseButtonProps } from './types';
77

8-
const baseButtonVariants = cva(
9-
'inline-flex size-fit cursor-pointer items-center justify-center rounded-full body-1-semi-bold transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus',
8+
export const baseButtonVariants = cva(
9+
'inline-flex size-fit cursor-pointer items-center justify-center rounded-full transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus',
1010
{
1111
variants: {
1212
appearance: {

libs/ui-react/src/lib/Components/Button/Button.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ const buttonVariants = cva('', {
77
variants: {
88
size: {
99
sm: 'px-16 py-10 body-2-semi-bold',
10-
md: 'px-16 py-12',
11-
lg: 'p-16',
10+
md: 'px-16 py-12 body-1-semi-bold',
11+
lg: 'p-16 body-1-semi-bold',
1212
},
1313
},
1414
defaultVariants: {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks';
2+
import * as ButtonTriggerStories from './ButtonTrigger.stories';
3+
4+
<Meta title='Components/ButtonTrigger' of={ButtonTriggerStories} />
5+
6+
# ButtonTrigger
7+
8+
## Introduction
9+
10+
A specialized trigger button designed exclusively for select and dropdown patterns. It displays a label with an optional leading icon and a permanent trailing chevron indicator.
11+
12+
> View in [Figma](https://www.figma.com/design/JxaLVMTWirCpU0rsbZ30k7/2.-Components-Library?node-id=6389-45680&m=dev).
13+
14+
> **Important**: This component should only be used as a trigger inside a Select or dropdown. For standalone actions, use [Button](/docs/action-button--docs) or [IconButton](/docs/action-iconbutton--docs) instead.
15+
16+
## Properties
17+
18+
<Canvas of={ButtonTriggerStories.Base} />
19+
<Controls of={ButtonTriggerStories.Base} />
20+
21+
## Appearance
22+
23+
Three appearances are available: `gray` (default), `transparent`, and `no-background`.
24+
25+
<Canvas of={ButtonTriggerStories.AllAppearancesWithIcons} />
26+
27+
## Sizes
28+
29+
<Canvas of={ButtonTriggerStories.SizeShowcase} />
30+
31+
## Icon Types
32+
33+
The `iconType` prop controls the padding scheme based on the leading icon's shape:
34+
35+
- **`flat`**: Standard padding for interface icons (line icons without background).
36+
- **`rounded`**: Tighter left padding for circular icons with their own background (e.g., crypto icons).
37+
38+
<Canvas of={ButtonTriggerStories.IconTypeShowcase} />
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { CryptoIcon } from '@ledgerhq/crypto-icons';
2+
import type { Meta, StoryObj } from '@storybook/react-vite';
3+
import type { ReactNode } from 'react';
4+
import { Settings, Star } from '../../Symbols';
5+
import { ButtonTrigger } from './ButtonTrigger';
6+
import type { ButtonTriggerProps } from './types';
7+
8+
type Size = NonNullable<ButtonTriggerProps['size']>;
9+
10+
const cryptoIconSizes = {
11+
sm: '24px',
12+
md: '32px',
13+
} as const;
14+
15+
const resolveIcon = (
16+
iconKey: string | undefined,
17+
size: Size = 'md',
18+
): { node?: ReactNode; type?: 'flat' | 'rounded' } => {
19+
switch (iconKey) {
20+
case 'Settings (flat)':
21+
return { node: <Settings size={20} />, type: 'flat' };
22+
case 'Bitcoin (rounded)':
23+
return {
24+
node: (
25+
<CryptoIcon
26+
ledgerId='bitcoin'
27+
ticker='BTC'
28+
size={cryptoIconSizes[size]}
29+
/>
30+
),
31+
type: 'rounded',
32+
};
33+
default:
34+
return {};
35+
}
36+
};
37+
38+
const meta: Meta<typeof ButtonTrigger> = {
39+
component: ButtonTrigger,
40+
title: 'Action/ButtonTrigger',
41+
parameters: {
42+
layout: 'centered',
43+
backgrounds: { default: 'light' },
44+
docs: {
45+
source: {
46+
language: 'tsx',
47+
format: true,
48+
type: 'code',
49+
},
50+
},
51+
},
52+
argTypes: {
53+
icon: {
54+
control: 'select',
55+
options: ['None', 'Settings (flat)', 'Bitcoin (rounded)'],
56+
},
57+
iconType: {
58+
control: 'select',
59+
options: ['flat', 'rounded'],
60+
},
61+
asChild: {
62+
control: false,
63+
},
64+
},
65+
};
66+
67+
export default meta;
68+
type Story = StoryObj<typeof ButtonTrigger>;
69+
70+
export const Base: Story = {
71+
args: {
72+
children: 'All accounts',
73+
appearance: 'gray',
74+
},
75+
render: ({ icon, size, iconType, ...args }) => {
76+
const resolved = resolveIcon(icon as string, size);
77+
return (
78+
<ButtonTrigger
79+
{...args}
80+
size={size}
81+
icon={resolved.node}
82+
iconType={resolved.type ?? iconType}
83+
>
84+
{args.children}
85+
</ButtonTrigger>
86+
);
87+
},
88+
};
89+
90+
export const SizeShowcase: Story = {
91+
render: () => (
92+
<div className='flex items-center gap-16'>
93+
<ButtonTrigger size='sm' icon={<Star size={20} />} iconType='flat'>
94+
Small
95+
</ButtonTrigger>
96+
<ButtonTrigger size='md' icon={<Star size={20} />} iconType='flat'>
97+
Medium
98+
</ButtonTrigger>
99+
</div>
100+
),
101+
};
102+
103+
export const IconTypeShowcase: Story = {
104+
render: () => (
105+
<div className='flex flex-col gap-16'>
106+
<div className='flex items-center gap-16'>
107+
<ButtonTrigger
108+
icon={<Settings size={20} />}
109+
iconType='flat'
110+
appearance='gray'
111+
>
112+
Flat icon (md)
113+
</ButtonTrigger>
114+
<ButtonTrigger
115+
icon={<CryptoIcon ledgerId='bitcoin' ticker='BTC' size='32px' />}
116+
iconType='rounded'
117+
appearance='gray'
118+
>
119+
Rounded icon (md)
120+
</ButtonTrigger>
121+
<ButtonTrigger appearance='gray'>No icon (md)</ButtonTrigger>
122+
</div>
123+
<div className='flex items-center gap-16'>
124+
<ButtonTrigger
125+
icon={<Settings size={20} />}
126+
iconType='flat'
127+
appearance='gray'
128+
size='sm'
129+
>
130+
Flat icon (sm)
131+
</ButtonTrigger>
132+
<ButtonTrigger
133+
icon={<CryptoIcon ledgerId='bitcoin' ticker='BTC' size='24px' />}
134+
iconType='rounded'
135+
appearance='gray'
136+
size='sm'
137+
>
138+
Rounded icon (sm)
139+
</ButtonTrigger>
140+
<ButtonTrigger appearance='gray' size='sm'>
141+
No icon (sm)
142+
</ButtonTrigger>
143+
</div>
144+
</div>
145+
),
146+
};
147+
148+
export const AllAppearancesWithIcons: Story = {
149+
render: () => {
150+
const appearances = ['gray', 'transparent', 'no-background'] as const;
151+
return (
152+
<div
153+
className='flex flex-col gap-16 p-16'
154+
style={{
155+
backgroundImage:
156+
'linear-gradient(45deg, #f2f2f2 25%, transparent 25%), ' +
157+
'linear-gradient(-45deg, #f2f2f2 25%, transparent 25%), ' +
158+
'linear-gradient(45deg, transparent 75%, #f2f2f2 75%), ' +
159+
'linear-gradient(-45deg, transparent 75%, #f2f2f2 75%)',
160+
backgroundSize: '20px 20px',
161+
}}
162+
>
163+
{appearances.map((appearance) => (
164+
<div key={appearance} className='flex items-center gap-16'>
165+
<ButtonTrigger appearance={appearance}>{appearance}</ButtonTrigger>
166+
<ButtonTrigger
167+
appearance={appearance}
168+
icon={<Settings size={20} />}
169+
iconType='flat'
170+
>
171+
{appearance}
172+
</ButtonTrigger>
173+
<ButtonTrigger
174+
appearance={appearance}
175+
icon={<CryptoIcon ledgerId='bitcoin' ticker='BTC' size='32px' />}
176+
iconType='rounded'
177+
>
178+
{appearance}
179+
</ButtonTrigger>
180+
</div>
181+
))}
182+
</div>
183+
);
184+
},
185+
};
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import { describe, it, expect, vi } from 'vitest';
3+
import '@testing-library/jest-dom';
4+
5+
import { Settings } from '../../Symbols';
6+
import { ButtonTrigger } from './ButtonTrigger';
7+
8+
describe('ButtonTrigger', () => {
9+
it('should render with label text', () => {
10+
render(<ButtonTrigger>All accounts</ButtonTrigger>);
11+
expect(
12+
screen.getByRole('button', { name: /all accounts/i }),
13+
).toBeInTheDocument();
14+
});
15+
16+
it('should always render a chevron icon', () => {
17+
const { container } = render(<ButtonTrigger>Label</ButtonTrigger>);
18+
const svg = container.querySelector('svg');
19+
expect(svg).toBeInTheDocument();
20+
});
21+
22+
it('should render with a flat interface icon', () => {
23+
const { container } = render(
24+
<ButtonTrigger
25+
icon={<Settings size={20} data-testid='icon' />}
26+
iconType='flat'
27+
>
28+
Network
29+
</ButtonTrigger>,
30+
);
31+
expect(screen.getByTestId('icon')).toBeInTheDocument();
32+
expect(container.querySelectorAll('svg').length).toBeGreaterThanOrEqual(2);
33+
});
34+
35+
it('should render with a rounded icon', () => {
36+
render(
37+
<ButtonTrigger
38+
icon={<span data-testid='crypto-icon'>BTC</span>}
39+
iconType='rounded'
40+
>
41+
Bitcoin
42+
</ButtonTrigger>,
43+
);
44+
expect(screen.getByTestId('crypto-icon')).toBeInTheDocument();
45+
expect(
46+
screen.getByRole('button', { name: /bitcoin/i }),
47+
).toBeInTheDocument();
48+
});
49+
50+
it('should be disabled when the disabled prop is true', () => {
51+
render(<ButtonTrigger disabled>Label</ButtonTrigger>);
52+
expect(screen.getByRole('button')).toBeDisabled();
53+
});
54+
55+
it('should call onClick handler when clicked', () => {
56+
const handleClick = vi.fn();
57+
render(<ButtonTrigger onClick={handleClick}>Label</ButtonTrigger>);
58+
fireEvent.click(screen.getByRole('button'));
59+
expect(handleClick).toHaveBeenCalledTimes(1);
60+
});
61+
62+
it('should not call onClick handler when disabled', () => {
63+
const handleClick = vi.fn();
64+
render(
65+
<ButtonTrigger onClick={handleClick} disabled>
66+
Label
67+
</ButtonTrigger>,
68+
);
69+
fireEvent.click(screen.getByRole('button'));
70+
expect(handleClick).not.toHaveBeenCalled();
71+
});
72+
73+
it('should forward ref to the button element', () => {
74+
const ref = vi.fn();
75+
render(<ButtonTrigger ref={ref}>Label</ButtonTrigger>);
76+
expect(ref).toHaveBeenCalledWith(expect.any(HTMLButtonElement));
77+
});
78+
79+
it('should apply custom className', () => {
80+
render(<ButtonTrigger className='ml-16'>Label</ButtonTrigger>);
81+
expect(screen.getByRole('button')).toHaveClass('ml-16');
82+
});
83+
});

0 commit comments

Comments
 (0)