Skip to content

Commit 4dd08e8

Browse files
authored
feat(components, protocol-designer) Add BasicButton to Helix (#18526)
* feat(components, protocol-designer) Add BasicButton to Helix
1 parent 8b0dc14 commit 4dd08e8

File tree

10 files changed

+294
-71
lines changed

10 files changed

+294
-71
lines changed
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import styled from 'styled-components'
2+
3+
import { COLORS } from '../../helix-design-system'
4+
import { Icon } from '../../icons'
5+
import { Flex } from '../../primitives'
6+
import { ALIGN_CENTER } from '../../styles'
7+
import { CURSOR_NOT_ALLOWED, CURSOR_POINTER } from '../../styles/cursor'
8+
import { SPACING, TYPOGRAPHY } from '../../ui-style-constants'
9+
import { StyledText } from '../StyledText/StyledText'
10+
11+
import type { MouseEvent } from 'react'
12+
import type { IconName } from '../../icons'
13+
14+
interface BasicButtonProps {
15+
children: string // Content of basic button
16+
onClick: (event: MouseEvent<HTMLButtonElement>) => void // Function to handle button click events
17+
isDisabled?: boolean // Optional prop to control button aria-disabled
18+
underLine?: boolean // Optional prop to control underline styling
19+
tabIndex?: number // Optional prop for tab index
20+
iconName?: IconName // Optional prop for icon
21+
}
22+
23+
export function BasicButton({
24+
children,
25+
onClick,
26+
isDisabled = false,
27+
underLine = false,
28+
tabIndex = 0,
29+
iconName,
30+
...props
31+
}: BasicButtonProps): JSX.Element {
32+
const handleButtonClick = (event: MouseEvent<HTMLButtonElement>): void => {
33+
if (isDisabled) {
34+
event.preventDefault()
35+
return
36+
}
37+
if (onClick != null) {
38+
onClick(event)
39+
}
40+
}
41+
42+
return (
43+
<StyledButton
44+
data-testid={`basic_button_${children}`}
45+
onClick={handleButtonClick}
46+
aria-disabled={isDisabled}
47+
underLine={underLine}
48+
tabIndex={tabIndex}
49+
{...props}
50+
>
51+
{iconName != null ? (
52+
<Flex alignItems={ALIGN_CENTER} gap={SPACING.spacing8}>
53+
<Icon
54+
name={iconName}
55+
size="1.25rem"
56+
data-testid={`basic_button_${iconName}`}
57+
/>
58+
<StyledText desktopStyle="bodyDefaultRegular">{children}</StyledText>
59+
</Flex>
60+
) : (
61+
<StyledText desktopStyle="bodyDefaultRegular">{children}</StyledText>
62+
)}
63+
</StyledButton>
64+
)
65+
}
66+
67+
const StyledButton = styled.button<{
68+
underLine?: boolean
69+
'aria-disabled'?: boolean
70+
}>`
71+
background: none;
72+
border: none;
73+
padding: ${SPACING.spacing4};
74+
75+
color: ${props => (props['aria-disabled'] ? COLORS.grey40 : COLORS.black90)};
76+
cursor: ${props =>
77+
props['aria-disabled'] ? CURSOR_NOT_ALLOWED : CURSOR_POINTER};
78+
79+
text-decoration: ${props =>
80+
props.underLine ? TYPOGRAPHY.textDecorationUnderline : 'none'};
81+
82+
${props =>
83+
!props['aria-disabled']
84+
? `
85+
&:hover {
86+
color: ${COLORS.blue50};
87+
}
88+
89+
&:focus-visible {
90+
color: ${COLORS.blue50};
91+
outline: 2px solid ${COLORS.blue50};
92+
outline-offset: ${SPACING.spacing4};
93+
}
94+
`
95+
: undefined}
96+
97+
&[aria-disabled="true"] {
98+
outline: none;
99+
}
100+
`
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { fireEvent, screen } from '@testing-library/react'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
import { COLORS } from '../../../helix-design-system'
5+
import { CURSOR_NOT_ALLOWED, CURSOR_POINTER } from '../../../styles/cursor'
6+
import { renderWithProviders } from '../../../testing/utils'
7+
import { TYPOGRAPHY } from '../../../ui-style-constants'
8+
import { BasicButton } from '../BasicButton'
9+
10+
import type { ComponentProps } from 'react'
11+
12+
const render = (props: ComponentProps<typeof BasicButton>) => {
13+
return renderWithProviders(<BasicButton {...props} />)
14+
}
15+
16+
describe('BasicButton', () => {
17+
let props: ComponentProps<typeof BasicButton>
18+
19+
beforeEach(() => {
20+
props = {
21+
children: 'basic button',
22+
onClick: vi.fn(),
23+
isDisabled: false,
24+
underLine: false,
25+
}
26+
})
27+
28+
it('renders basic button with text', async () => {
29+
render(props)
30+
const button = screen.getByRole('button', { name: 'basic button' })
31+
expect(button).toHaveAttribute('aria-disabled', 'false')
32+
expect(button).toHaveStyle(`cursor: ${CURSOR_POINTER}`)
33+
expect(button).toHaveStyle(`color: ${COLORS.black90}`)
34+
expect(button).toHaveStyle(`text-decoration: none`)
35+
})
36+
37+
it('renders basic button with icon', async () => {
38+
props.iconName = 'alert'
39+
render(props)
40+
screen.getByTestId('basic_button_alert')
41+
screen.getByRole('button', { name: 'basic button' })
42+
})
43+
44+
// TODO: need to update '@testing-library/user-event' v14+
45+
// it('has hover styles when not disabled', async () => {
46+
// render(props)
47+
// const button = screen.getByRole('button', { name: 'basic button' })
48+
// await userEvent.hover(button)
49+
// expect(button).toHaveStyle(`color: ${COLORS.blue50}`)
50+
// })
51+
52+
it('calls onClick when clicked', () => {
53+
render(props)
54+
const button = screen.getByRole('button', { name: 'basic button' })
55+
fireEvent.click(button)
56+
expect(props.onClick).toHaveBeenCalled()
57+
})
58+
59+
it('renders basic button with text and aria-disabled', () => {
60+
props.isDisabled = true
61+
render(props)
62+
const button = screen.getByRole('button', { name: 'basic button' })
63+
expect(button).toHaveAttribute('aria-disabled', 'true')
64+
expect(button).toHaveStyle(`cursor: ${CURSOR_NOT_ALLOWED}`)
65+
expect(button).toHaveStyle(`color: ${COLORS.grey40}`)
66+
})
67+
68+
it('renders basic button with text and underline', () => {
69+
props.underLine = true
70+
render(props)
71+
const button = screen.getByRole('button', { name: 'basic button' })
72+
expect(button).toHaveAttribute('aria-disabled', 'false')
73+
expect(button).toHaveStyle(`cursor: ${CURSOR_POINTER}`)
74+
expect(button).toHaveStyle(`color: ${COLORS.black90}`)
75+
expect(button).toHaveStyle(
76+
`text-decoration: ${TYPOGRAPHY.textDecorationUnderline}`
77+
)
78+
})
79+
80+
it('has hover styles when not disabled', () => {
81+
render(props)
82+
const button = screen.getByRole('button', { name: 'basic button' })
83+
expect(button).toHaveStyle(`color: ${COLORS.black90}`)
84+
expect(button).toHaveAttribute('aria-disabled', 'false')
85+
expect(button).toHaveStyle(`cursor: ${CURSOR_POINTER}`)
86+
})
87+
})

components/src/atoms/buttons/buttons.stories.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { ICON_DATA_BY_NAME } from '../../icons/icon-data'
12
import { Flex, STYLE_PROPS } from '../../primitives'
23
import { DIRECTION_ROW } from '../../styles'
34
import { SPACING } from '../../ui-style-constants'
45
import { AlertPrimaryButton as AlertPrimaryButtonComponent } from './AlertPrimaryButton'
56
import { AltPrimaryButton as AltPrimaryButtonComponent } from './AltPrimaryButton'
7+
import { BasicButton as BasicButtonComponent } from './BasicButton'
68
import { PrimaryButton as PrimaryButtonComponent } from './PrimaryButton'
79
import { SecondaryButton as SecondaryButtonComponent } from './SecondaryButton'
810

@@ -82,3 +84,31 @@ export const AltPrimaryButton: StoryObj<typeof AltPrimaryButtonComponent> = {
8284
</Flex>
8385
),
8486
}
87+
88+
export const BasicButton: StoryObj<typeof BasicButtonComponent> = {
89+
args: {
90+
children: 'basic button',
91+
},
92+
argTypes: {
93+
underLine: {
94+
control: {
95+
type: 'boolean',
96+
},
97+
description:
98+
'Toggles the underline style for the button text (BasicButton only).',
99+
},
100+
iconName: {
101+
options: Object.keys(ICON_DATA_BY_NAME),
102+
control: {
103+
type: 'select',
104+
},
105+
description:
106+
'Optional icon to display alongside the button text (BasicButton only).',
107+
},
108+
},
109+
render: args => (
110+
<Flex flexDirection={DIRECTION_ROW} gridGap={SPACING.spacing16}>
111+
<BasicButtonComponent {...args} />
112+
</Flex>
113+
),
114+
}

components/src/atoms/buttons/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './AlertPrimaryButton'
2+
export * from './BasicButton'
23
export * from './EmptySelectorButton'
34
export * from './LargeButton'
45
export * from './PrimaryButton'

protocol-designer/src/components/organisms/Navigation/index.tsx

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
import { useRef } from 'react'
12
import { useTranslation } from 'react-i18next'
23
import { useDispatch, useSelector } from 'react-redux'
34
import { useLocation, useNavigate } from 'react-router-dom'
45
import styled from 'styled-components'
56

67
import {
78
ALIGN_CENTER,
8-
Btn,
9+
BasicButton,
910
COLORS,
1011
CURSOR_POINTER,
1112
Flex,
@@ -17,7 +18,6 @@ import {
1718
import { actions as loadFileActions } from '../../../load-file'
1819
import { getHasUnsavedChanges } from '../../../load-file/selectors'
1920
import { toggleNewProtocolModal } from '../../../navigation/actions'
20-
import { LINK_BUTTON_STYLE } from '../../atoms'
2121
import { SettingsIcon } from '../SettingsIcon'
2222

2323
import type { ChangeEvent } from 'react'
@@ -28,6 +28,7 @@ export function Navigation(): JSX.Element | null {
2828
const location = useLocation()
2929
const navigate = useNavigate()
3030
const dispatch: ThunkDispatch<any> = useDispatch()
31+
const fileInputRef = useRef<HTMLInputElement>(null)
3132
const loadFile = (fileChangeEvent: ChangeEvent<HTMLInputElement>): void => {
3233
dispatch(loadFileActions.loadProtocolFile(fileChangeEvent))
3334
dispatch(toggleNewProtocolModal(false))
@@ -44,6 +45,12 @@ export function Navigation(): JSX.Element | null {
4445
}
4546
}
4647

48+
const handleImport = (): void => {
49+
if (fileInputRef.current != null) {
50+
fileInputRef.current.click()
51+
}
52+
}
53+
4754
return (
4855
<Flex
4956
justifyContent={JUSTIFY_SPACE_BETWEEN}
@@ -58,18 +65,15 @@ export function Navigation(): JSX.Element | null {
5865
</StyledText>
5966
</Flex>
6067
<Flex gridGap={SPACING.spacing40} alignItems={ALIGN_CENTER}>
61-
<Btn onClick={handleCreateNew} css={LINK_BUTTON_STYLE}>
62-
<StyledText desktopStyle="bodyDefaultRegular">
63-
{t('create_new')}
64-
</StyledText>
65-
</Btn>
68+
<BasicButton onClick={handleCreateNew}>{t('create_new')}</BasicButton>
6669
<StyledLabel>
67-
<Flex css={LINK_BUTTON_STYLE}>
68-
<StyledText desktopStyle="bodyDefaultRegular">
69-
{t('import')}
70-
</StyledText>
71-
</Flex>
72-
<input type="file" onChange={loadFile} aria-label={t('import')} />
70+
<BasicButton onClick={handleImport}>{t('import')}</BasicButton>
71+
<input
72+
type="file"
73+
onChange={loadFile}
74+
aria-label={t('import')}
75+
ref={fileInputRef}
76+
/>
7377
</StyledLabel>
7478
{location.pathname === '/createNew' ? null : <SettingsIcon />}
7579
</Flex>
@@ -78,7 +82,6 @@ export function Navigation(): JSX.Element | null {
7882
}
7983

8084
const StyledLabel = styled.label`
81-
height: 1.25rem;
8285
cursor: ${CURSOR_POINTER};
8386
input[type='file'] {
8487
display: none;

protocol-designer/src/components/organisms/Settings/AppInfo.tsx

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,16 @@ import { useTranslation } from 'react-i18next'
22

33
import {
44
ALIGN_CENTER,
5-
Btn,
5+
BasicButton,
66
DIRECTION_COLUMN,
77
Flex,
88
JUSTIFY_SPACE_BETWEEN,
9-
Link as LinkComponent,
109
ListItem,
1110
SPACING,
1211
StyledText,
13-
TYPOGRAPHY,
1412
} from '@opentrons/components'
1513

1614
import { DOC_URL } from '..'
17-
import { LINK_BUTTON_STYLE } from '../../atoms'
1815

1916
import type { Dispatch, SetStateAction } from 'react'
2017

@@ -28,6 +25,10 @@ export function AppInfo({
2825
const { t } = useTranslation('shared')
2926
const pdVersion = process.env.OT_PD_VERSION
3027

28+
const handleSoftwareManualClick = (): void => {
29+
window.open(DOC_URL, '_blank')
30+
}
31+
3132
return (
3233
<Flex
3334
flexDirection={DIRECTION_COLUMN}
@@ -51,31 +52,17 @@ export function AppInfo({
5152
<StyledText desktopStyle="bodyDefaultRegular">{pdVersion}</StyledText>
5253
</Flex>
5354
<Flex gridGap={SPACING.spacing16} alignItems={ALIGN_CENTER}>
54-
<LinkComponent
55-
css={LINK_BUTTON_STYLE}
56-
textDecoration={TYPOGRAPHY.textDecorationUnderline}
57-
href={DOC_URL}
58-
external
59-
padding={SPACING.spacing4}
60-
>
61-
<StyledText desktopStyle="bodyDefaultRegular">
62-
{t('software_manual')}
63-
</StyledText>
64-
</LinkComponent>
65-
66-
<Btn
67-
css={LINK_BUTTON_STYLE}
68-
textDecoration={TYPOGRAPHY.textDecorationUnderline}
55+
<BasicButton onClick={handleSoftwareManualClick} underLine>
56+
{t('software_manual')}
57+
</BasicButton>
58+
<BasicButton
6959
onClick={() => {
7060
setShowAnnouncementModal(true)
7161
}}
72-
data-testid="AnnouncementModal_viewReleaseNotesButton"
73-
padding={SPACING.spacing4}
62+
underLine
7463
>
75-
<StyledText desktopStyle="bodyDefaultRegular">
76-
{t('release_notes')}
77-
</StyledText>
78-
</Btn>
64+
{t('release_notes')}
65+
</BasicButton>
7966
</Flex>
8067
</ListItem>
8168
</Flex>

0 commit comments

Comments
 (0)