Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ActionIsland): new component #1362

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
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
32 changes: 32 additions & 0 deletions src/components/ActionIsland/ActionButton/ActionButton.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
@keyframes dropdownAnimation {
from {
opacity: 0;
transform: translateY(var(--space-250));
}
to {
opacity: 1;
transform: translateY(0);
}
}

.ActionButton {
&__icon {
margin-left: var(--space-50);
fill: var(--color-text-brand-default);

&--isOpen {
transform: rotateX(180deg);
}

&--isGroupDisabled {
fill: var(--color-text-brand-disabled);
}
}

&__dropdown {
min-width: 200px;
width: fit-content;
margin-bottom: var(--space-100);
animation: dropdownAnimation 300ms ease-out;
}
}
113 changes: 113 additions & 0 deletions src/components/ActionIsland/ActionButton/ActionButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import * as React from 'react';
import ExpandIcon from '@material-design-icons/svg/round/expand_more.svg';
import { bem } from '../../../utils';
import { Button, ButtonProps } from '../../Buttons';
import { Separator } from '../../Dropdown/Separator';
import {
DropdownContent,
DropdownItem,
DropdownPortal,
DropdownRoot,
DropdownTrigger,
SingleSelectItem,
} from '../../Dropdown';
import styles from './ActionButton.scss';
import { Tooltip } from '../../Tooltip';

const { elem } = bem('ActionButton', styles);

export interface ActionButtonProps extends Omit<ButtonProps, 'children'> {
/** Label of the button or the group of buttons */
label: React.ReactNode;
/** Dropdown items for the button, supports nested dropdowns/groups */
dropdownItems?: React.ReactNode[] | ActionButtonProps[];
/** Click handler for the button */
onClick?: () => void;
/** Indicates if this button represents a group of buttons */
isGroup?: boolean;
/** Indicates if this group is disabled */
isGroupDisabled?: boolean;
/** Tooltip content for the button, which appears on hover */
tooltipContent?: string;
}

export const ActionButton: React.FC<ActionButtonProps> = ({
label,
dropdownItems,
onClick,
tooltipContent,
isGroup,
isGroupDisabled,
...rest
}) => {
const [isOpen, setIsOpen] = React.useState(false);

const handleOpenStateChange = (open: boolean) => {
setIsOpen(open);
};

return (
<>
{dropdownItems?.length ? (
<DropdownRoot onOpenChange={handleOpenStateChange}>
<DropdownTrigger asChild disabled={isGroupDisabled}>
<Button type="button" context="primary" variant="ghost" {...rest}>
{label}
{dropdownItems && (
<ExpandIcon
{...elem('icon', { isOpen, isGroupDisabled })}
viewBox="0 0 24 24"
height="20px"
width="20px"
/>
)}
</Button>
</DropdownTrigger>
<DropdownPortal>
<DropdownContent
{...elem('dropdown')}
role="menu"
align="end"
collisionPadding={8}
>
{dropdownItems.map((item) =>
item.isGroup ? (
<DropdownItem key={`${item.label}`}>
<Separator>{item.label}</Separator>
{item.dropdownItems &&
item.dropdownItems.map((subItem) => (
<SingleSelectItem
key={`${subItem.label}`}
onClick={subItem.onClick}
>
{subItem.label}
</SingleSelectItem>
))}
</DropdownItem>
) : (
<SingleSelectItem onClick={item.onClick}>
{item.label}
</SingleSelectItem>
)
)}
</DropdownContent>
</DropdownPortal>
</DropdownRoot>
) : (
<Tooltip placement="top" content={tooltipContent}>
<Button
type="button"
context="primary"
variant="ghost"
onClick={onClick}
{...rest}
>
{label}
</Button>
</Tooltip>
)}
</>
);
};

ActionButton.displayName = 'ActionButton';
1 change: 1 addition & 0 deletions src/components/ActionIsland/ActionButton/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ActionButton, type ActionButtonProps } from './ActionButton';
35 changes: 35 additions & 0 deletions src/components/ActionIsland/ActionIsland.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@keyframes islandAnimation {
0% {
transform: translateX(-50%) translateY(100%);
opacity: 0;
}
100% {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
}

.ActionIsland {
width: fit-content;
padding: var(--space-50) var(--space-100) var(--space-50) var(--space-200);
margin-bottom: var(--space-200);
position: fixed;
bottom: 0;
border-radius: var(--space-100);
background-color: var(--color-background);
display: flex;
align-items: center;
gap: var(--space-100);
z-index: 1000;
left: 50%;
transform: translateX(-50%);
animation: islandAnimation 300ms ease-out;
white-space: nowrap;
overflow: hidden;

&__label {
padding-right: var(--space-100);
border-right: 1px solid var(--color-border-disabled);
text-wrap: nowrap;
}
}
79 changes: 79 additions & 0 deletions src/components/ActionIsland/ActionIsland.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import * as React from 'react';
import Close from '@material-design-icons/svg/round/close.svg';
import { bem } from '../../utils';
import { IconButton } from '../Buttons/IconButton/IconButton';
import { Text } from '../Text';
import styles from './ActionIsland.scss';
import { ActionButton } from './ActionButton';
import { Tooltip } from '../Tooltip/Tooltip';
import { ActionButtonProps } from './ActionButton/ActionButton';

export interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
actionButtons: ActionButtonProps[];
/** Determines if the action island is shown. */
isShown: boolean;
/** Callback function triggered when the close button is clicked. */
onClose: () => void;
/** Counter part or prefix of the label. */
size: React.ReactNode;
/** Main label to be displayed */
label: React.ReactNode;
/** Label for the "More" button */
moreButtonLabel?: string;
/** Close button label name for ARIA labelling */
closeButtonLabel?: string;
/** Tooltip content for the close button, which appears on hover */
closeButtonTooltip?: string;
}

const { block, elem } = bem('ActionIsland', styles);

export const ActionIsland: React.FC<Props> = ({
actionButtons,
isShown,
onClose,
size,
label,
moreButtonLabel,
closeButtonLabel,
closeButtonTooltip,
...rest
}) => {
const visibleButtons = actionButtons.slice(0, 3);
const overflowButtons = actionButtons.slice(3);

return (
isShown && (
<div {...block()} {...rest}>
<div {...elem('header')}>
<Text inline {...elem('label')}>
<Text inline isBold>
{size}
</Text>{' '}
{label}
</Text>
</div>
<div {...elem('actionsContainer')}>
{visibleButtons.map((button) => (
<ActionButton key={`${button.label}`} {...button} />
))}
{overflowButtons.length > 0 && (
<ActionButton label={moreButtonLabel} dropdownItems={overflowButtons} />
)}
</div>
<Tooltip placement="top" content={closeButtonTooltip}>
<IconButton
variant="ghost"
size="large"
onClick={onClose}
aria-label={closeButtonLabel}
>
<Close viewBox="0 0 24 24" />
</IconButton>
</Tooltip>
</div>
)
);
};

ActionIsland.displayName = 'ActionIsland';
57 changes: 57 additions & 0 deletions src/components/ActionIsland/__tests__/ActionButton.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import { ActionButton, ActionButtonProps } from '../ActionButton';

const mockClick = jest.fn();

const defaultButtonProps: ActionButtonProps = {
label: 'Test Button',
onClick: mockClick,
};

describe('ActionButton', () => {
it('renders correctly with label', () => {
const { container } = render(<ActionButton {...defaultButtonProps} />);
expect(screen.getByText('Test Button')).toBeInTheDocument();
expect(container).toMatchSnapshot();
});

it('handles click event correctly', async () => {
render(<ActionButton {...defaultButtonProps} />);
await userEvent.click(screen.getByText('Test Button'));
expect(mockClick).toHaveBeenCalled();
});

it('renders dropdown items correctly when provided', async () => {
const dropdownItems = [
{ label: 'Option 1', onClick: jest.fn() },
{ label: 'Option 2', onClick: jest.fn() },
];
const { container } = render(
<ActionButton {...defaultButtonProps} dropdownItems={dropdownItems} />
);
expect(screen.getByText('Test Button')).toBeInTheDocument();
await userEvent.click(screen.getByText('Test Button'));
expect(await screen.findByText('Option 1')).toBeInTheDocument();
expect(await screen.findByText('Option 2')).toBeInTheDocument();
expect(container).toMatchSnapshot();
});

it('does not open dropdown when no items are provided', () => {
const { container } = render(
<ActionButton {...defaultButtonProps} tooltipContent="Tooltip Content" />
);
const button = screen.getByText('Test Button');
userEvent.click(button);
expect(screen.queryByText('Option 1')).not.toBeInTheDocument();
expect(container).toMatchSnapshot();
});

it('renders tooltip correctly', async () => {
render(<ActionButton {...defaultButtonProps} tooltipContent="Tooltip Content" />);
await userEvent.hover(screen.getByText('Test Button'));
expect(screen.getByText('Tooltip Content')).toBeInTheDocument();
});
});
54 changes: 54 additions & 0 deletions src/components/ActionIsland/__tests__/ActionIsland.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import { ActionIsland, Props } from '../ActionIsland';

const mockOnClose = jest.fn();

const defaultProps: Props = {
actionButtons: [
{ label: 'Action 1', onClick: jest.fn() },
{ label: 'Action 2', onClick: jest.fn() },
{ label: 'Action 3', onClick: jest.fn() },
{ label: 'Overflow Action', onClick: jest.fn() },
],
isShown: true,
onClose: mockOnClose,
size: 'Large',
label: 'Test Label',
moreButtonLabel: 'More',
closeButtonLabel: 'Close button',
closeButtonTooltip: 'Close Tooltip',
};

describe('ActionIsland', () => {
it('renders correctly with all props', () => {
const { container } = render(<ActionIsland {...defaultProps} />);
expect(screen.getByText('Test Label')).toBeInTheDocument();
expect(screen.getByText('Action 1')).toBeInTheDocument();
expect(screen.getByText('More')).toBeInTheDocument();
expect(container).toMatchSnapshot();
});

it('handles close button click correctly', async () => {
render(<ActionIsland {...defaultProps} />);
const user = userEvent.setup();
const closeButton = screen.getByRole('button', { name: /close/i });
await user.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});

it('renders visible and overflow buttons correctly', () => {
const { container } = render(<ActionIsland {...defaultProps} />);
expect(screen.getByText('Action 1')).toBeInTheDocument();
expect(screen.getByText('More')).toBeInTheDocument();
expect(container).toMatchSnapshot();
});

it('does not render when `isShown` is false', () => {
const { container } = render(<ActionIsland {...defaultProps} isShown={false} />);
expect(screen.queryByText('Test Label')).not.toBeInTheDocument();
expect(container).toMatchSnapshot();
});
});
Loading
Loading