diff --git a/src/components/experimental/IconButton/IconButton.spec.tsx b/src/components/experimental/IconButton/IconButton.spec.tsx new file mode 100644 index 000000000..5d0ef759c --- /dev/null +++ b/src/components/experimental/IconButton/IconButton.spec.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { IconButton } from './IconButton'; +import { TrashIcon } from '../../../icons'; + +describe('Experimental: IconButton', () => { + it('renders an icon button with the provided icon', () => { + const onPress = jest.fn(); + render(); + expect(screen.getByTestId('standard-icon-container')).toBeInTheDocument(); + }); + + it('calls onPress when clicked', () => { + const onPress = jest.fn(); + render(); + screen.getByTestId('standard-icon-container').click(); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('does not call onPress when disabled', () => { + const onPress = jest.fn(); + render(); + screen.getByTestId('standard-icon-container').click(); + expect(onPress).toHaveBeenCalledTimes(0); + }); + + it('sets the right sizes for standard variant', () => { + const onPress = jest.fn(); + render(); + const iconContainerInstance = screen.getByTestId('standard-icon-container'); + const containerStyle = window.getComputedStyle(iconContainerInstance); + expect(containerStyle.width).toBe('2.5rem'); + expect(containerStyle.height).toBe('2.5rem'); + expect(containerStyle.borderRadius).toBe('100%'); + }); + + it('sets the right sizes for tonal variant', () => { + const onPress = jest.fn(); + render(); + const iconContainerInstance = screen.getByTestId('tonal-icon-container'); + const containerStyle = window.getComputedStyle(iconContainerInstance); + expect(containerStyle.width).toBe('3.5rem'); + expect(containerStyle.height).toBe('3.5rem'); + expect(containerStyle.borderRadius).toBe('100%'); + }); +}); diff --git a/src/components/experimental/IconButton/IconButton.tsx b/src/components/experimental/IconButton/IconButton.tsx new file mode 100644 index 000000000..488c10039 --- /dev/null +++ b/src/components/experimental/IconButton/IconButton.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import styled from 'styled-components'; +import { ButtonProps, Button } from 'react-aria-components'; +import { IconProps } from '../../../icons'; +import { getSemanticValue } from '../../../essentials/experimental'; + +export interface IconButtonProps extends ButtonProps { + isActive?: boolean; + variant?: 'standard' | 'tonal'; + Icon: React.FC; + onPress: () => void; +} + +const StandardIconContainer = styled(Button)>` + height: 2.5rem; + width: 2.5rem; + border-radius: 100%; + background-color: transparent; + border-color: transparent; + + /* we create a before pseudo element to mess with the opacity (see the hovered state) */ + &::before { + position: absolute; + content: ''; + border-radius: inherit; + opacity: 0; + height: inherit; + width: inherit; + } + + /* we want to change the opacity here but not affect the icon, so we have to use the before pseudo element */ + &[data-hovered]::before { + opacity: 0.16; + background-color: ${getSemanticValue('on-surface')}; + } + + display: flex; + align-items: center; + justify-content: center; + + &:not([data-disabled]) { + color: ${props => (props.isActive ? getSemanticValue('interactive') : getSemanticValue('on-surface'))}; + } + + &[data-disabled] { + opacity: 0.38; + } +`; + +const TonalIconContainer = styled(Button)>` + height: 3.5rem; + width: 3.5rem; + border-radius: 100%; + border-color: transparent; + background: none; + + /* we create a before pseudo element to mess with the opacity (see the hovered state) */ + &::before { + position: absolute; + content: ''; + border-radius: inherit; + height: inherit; + width: inherit; + background-color: ${props => + props.isActive && !props.isDisabled + ? getSemanticValue('interactive-container') + : getSemanticValue('surface')}; + z-index: -1; + } + + /* we want to change the opacity here but not affect the icon, so we have to use the before pseudo element */ + &[data-hovered]::before { + background-color: color-mix( + in hsl, + ${getSemanticValue('on-surface')} 100%, + ${props => (props.isActive ? getSemanticValue('interactive-container') : getSemanticValue('on-surface'))} + 100% + ); + opacity: 0.16; + } + + display: flex; + align-items: center; + justify-content: center; + + &:not([data-disabled]) { + color: ${props => + props.isActive ? getSemanticValue('on-interactive-container') : getSemanticValue('on-surface')}; + } + + &[data-disabled] { + opacity: 0.38; + } +`; + +export const IconButton = ({ + isDisabled = false, + isActive = false, + Icon, + variant = 'standard', + onPress +}: IconButtonProps) => + variant === 'standard' ? ( + + + + ) : ( + + + + ); diff --git a/src/components/experimental/IconButton/docs/IconButton.stories.tsx b/src/components/experimental/IconButton/docs/IconButton.stories.tsx new file mode 100644 index 000000000..fc5ab0208 --- /dev/null +++ b/src/components/experimental/IconButton/docs/IconButton.stories.tsx @@ -0,0 +1,47 @@ +import { StoryObj, Meta } from '@storybook/react'; +import { IconButton } from '../IconButton'; +import { TrashIcon } from '../../../../icons'; + +const meta: Meta = { + title: 'Experimental/Components/IconButton', + component: IconButton, + parameters: { + layout: 'centered' + }, + args: { + Icon: TrashIcon, + onPress: () => alert('Clicked!'), + isDisabled: false + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Disabled: Story = { + args: { + isDisabled: true + } +}; + +export const Active: Story = { + args: { + isActive: true + } +}; + +export const Tonal: Story = { + args: { + variant: 'tonal' + } +}; + +export const TonalActive: Story = { + args: { + variant: 'tonal', + isActive: true + } +}; diff --git a/src/components/experimental/index.ts b/src/components/experimental/index.ts index 61be18907..873ce5800 100644 --- a/src/components/experimental/index.ts +++ b/src/components/experimental/index.ts @@ -4,6 +4,8 @@ export { Chip } from './Chip/Chip'; export { ComboBox } from './ComboBox/ComboBox'; export { DateField } from './DateField/DateField'; export { DatePicker } from './DatePicker/DatePicker'; +export { Divider } from './Divider/Divider'; +export { IconButton } from './IconButton/IconButton'; export { Label } from './Label/Label'; export { ListBox, ListBoxItem } from './ListBox/ListBox'; export { Popover } from './Popover/Popover';