diff --git a/src/components/ActionIsland/ActionButton/ActionButton.scss b/src/components/ActionIsland/ActionButton/ActionButton.scss new file mode 100644 index 000000000..0b2390f8a --- /dev/null +++ b/src/components/ActionIsland/ActionButton/ActionButton.scss @@ -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; + } +} diff --git a/src/components/ActionIsland/ActionButton/ActionButton.tsx b/src/components/ActionIsland/ActionButton/ActionButton.tsx new file mode 100644 index 000000000..1d7ae76ee --- /dev/null +++ b/src/components/ActionIsland/ActionButton/ActionButton.tsx @@ -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 { + /** 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 = ({ + label, + dropdownItems, + onClick, + tooltipContent, + isGroup, + isGroupDisabled, + ...rest +}) => { + const [isOpen, setIsOpen] = React.useState(false); + + const handleOpenStateChange = (open: boolean) => { + setIsOpen(open); + }; + + return ( + <> + {dropdownItems?.length ? ( + + + + + + + {dropdownItems.map((item) => + item.isGroup ? ( + + {item.label} + {item.dropdownItems && + item.dropdownItems.map((subItem) => ( + + {subItem.label} + + ))} + + ) : ( + + {item.label} + + ) + )} + + + + ) : ( + + + + )} + + ); +}; + +ActionButton.displayName = 'ActionButton'; diff --git a/src/components/ActionIsland/ActionButton/index.ts b/src/components/ActionIsland/ActionButton/index.ts new file mode 100644 index 000000000..a6177c43e --- /dev/null +++ b/src/components/ActionIsland/ActionButton/index.ts @@ -0,0 +1 @@ +export { ActionButton, type ActionButtonProps } from './ActionButton'; diff --git a/src/components/ActionIsland/ActionIsland.scss b/src/components/ActionIsland/ActionIsland.scss new file mode 100644 index 000000000..a375d5522 --- /dev/null +++ b/src/components/ActionIsland/ActionIsland.scss @@ -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; + } +} diff --git a/src/components/ActionIsland/ActionIsland.tsx b/src/components/ActionIsland/ActionIsland.tsx new file mode 100644 index 000000000..3e5045e1d --- /dev/null +++ b/src/components/ActionIsland/ActionIsland.tsx @@ -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, '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 = ({ + actionButtons, + isShown, + onClose, + size, + label, + moreButtonLabel, + closeButtonLabel, + closeButtonTooltip, + ...rest +}) => { + const visibleButtons = actionButtons.slice(0, 3); + const overflowButtons = actionButtons.slice(3); + + return ( + isShown && ( +
+
+ + + {size} + {' '} + {label} + +
+
+ {visibleButtons.map((button) => ( + + ))} + {overflowButtons.length > 0 && ( + + )} +
+ + + + + +
+ ) + ); +}; + +ActionIsland.displayName = 'ActionIsland'; diff --git a/src/components/ActionIsland/__tests__/ActionButton.spec.tsx b/src/components/ActionIsland/__tests__/ActionButton.spec.tsx new file mode 100644 index 000000000..823af6705 --- /dev/null +++ b/src/components/ActionIsland/__tests__/ActionButton.spec.tsx @@ -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(); + expect(screen.getByText('Test Button')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + + it('handles click event correctly', async () => { + render(); + 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( + + ); + 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( + + ); + 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(); + await userEvent.hover(screen.getByText('Test Button')); + expect(screen.getByText('Tooltip Content')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ActionIsland/__tests__/ActionIsland.spec.tsx b/src/components/ActionIsland/__tests__/ActionIsland.spec.tsx new file mode 100644 index 000000000..c2fc43798 --- /dev/null +++ b/src/components/ActionIsland/__tests__/ActionIsland.spec.tsx @@ -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(); + 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(); + 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(); + 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(); + expect(screen.queryByText('Test Label')).not.toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/components/ActionIsland/__tests__/__snapshots__/ActionButton.spec.tsx.snap b/src/components/ActionIsland/__tests__/__snapshots__/ActionButton.spec.tsx.snap new file mode 100644 index 000000000..21abac233 --- /dev/null +++ b/src/components/ActionIsland/__tests__/__snapshots__/ActionButton.spec.tsx.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ActionButton does not open dropdown when no items are provided 1`] = ` +
+ +
+`; + +exports[`ActionButton renders correctly with label 1`] = ` +
+ +
+`; + +exports[`ActionButton renders dropdown items correctly when provided 1`] = ` + +`; diff --git a/src/components/ActionIsland/__tests__/__snapshots__/ActionIsland.spec.tsx.snap b/src/components/ActionIsland/__tests__/__snapshots__/ActionIsland.spec.tsx.snap new file mode 100644 index 000000000..cb4c1d5a9 --- /dev/null +++ b/src/components/ActionIsland/__tests__/__snapshots__/ActionIsland.spec.tsx.snap @@ -0,0 +1,143 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ActionIsland does not render when \`isShown\` is false 1`] = `
`; + +exports[`ActionIsland renders correctly with all props 1`] = ` +
+
+
+ + + Large + + + Test Label + +
+
+ + + + +
+ +
+
+`; + +exports[`ActionIsland renders visible and overflow buttons correctly 1`] = ` +
+
+
+ + + Large + + + Test Label + +
+
+ + + + +
+ +
+
+`; diff --git a/src/components/ActionIsland/index.ts b/src/components/ActionIsland/index.ts new file mode 100644 index 000000000..1aa8ca347 --- /dev/null +++ b/src/components/ActionIsland/index.ts @@ -0,0 +1 @@ +export { ActionIsland } from './ActionIsland'; diff --git a/src/components/Dropdown/Separator/Separator.tsx b/src/components/Dropdown/Separator/Separator.tsx index 4337d1a92..e7f814ca9 100644 --- a/src/components/Dropdown/Separator/Separator.tsx +++ b/src/components/Dropdown/Separator/Separator.tsx @@ -8,7 +8,7 @@ import classnames from './Separators.scss'; export interface Props extends DropdownMenuSeparatorProps { /** title of the section: optional */ - children?: string; + children?: string | React.ReactNode; } const titleBlock = bem('SectionTitle', classnames); diff --git a/src/index.ts b/src/index.ts index 7ec90b421..60820381d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,6 +72,7 @@ export * from './components/TwoPaneView'; export * from './components/WeightedResultBar'; export * from './components/NumericStepper'; // Organisms +export * from './components/ActionIsland'; export * from './components/Dialogs'; export * from './components/SelectedItemBadge'; export * from './components/SelectComponents'; diff --git a/stories/organisms/ActionIsland.stories.tsx b/stories/organisms/ActionIsland.stories.tsx new file mode 100644 index 000000000..bea1adc0e --- /dev/null +++ b/stories/organisms/ActionIsland.stories.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { ActionIsland, Button } from '@textkernel/oneui'; + +const meta: Meta = { + title: 'Organisms/ActionIsland', + component: ActionIsland, +}; + +export default meta; + +type Story = StoryObj; + +export const _ActionIsland: Story = { + name: 'ActionIsland', + args: { + label: 'candidates selected', + size: '10', + actionButtons: [ + { label: 'Share', onClick: () => console.log('Share clicked') }, + { + label: 'Export', + dropdownItems: [ + { label: 'Export as PDF', onClick: () => console.log('Export PDF clicked') }, + { label: 'Export as CSV', onClick: () => console.log('Export CSV clicked') }, + ], + isGroup: true, + isGroupDisabled: true, + tooltipContent: 'Export', + }, + { + label: 'ATS Actions', + isGroup: true, + dropdownItems: [ + { label: 'Candidate overview', onClick: () => console.log('Overview clicked') }, + { label: 'Email candidate', onClick: () => console.log('Email clicked') }, + ], + }, + { + label: 'Textkernel Actions', + isGroup: true, + dropdownItems: [ + { label: 'Action 1', onClick: () => console.log('Action 1 clicked') }, + { label: 'Action 2', onClick: () => console.log('Action 2 clicked') }, + ], + }, + ], + moreButtonLabel: 'More', + closeButtonTooltip: 'Close', + }, + + render: (args) => { + const [isShown, setIsShown] = React.useState(false); + + const handleOnClose = () => { + setIsShown(false); + console.log('OnClose was called'); + }; + + return ( + <> + + + + ); + }, +};