diff --git a/.changeset/collection-toolbar.md b/.changeset/collection-toolbar.md new file mode 100644 index 0000000000..7013fb106c --- /dev/null +++ b/.changeset/collection-toolbar.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/collection-toolbar': minor +--- + +Initial release of `CollectionToolbar` diff --git a/packages/collection-toolbar/README.md b/packages/collection-toolbar/README.md new file mode 100644 index 0000000000..a4910e10dd --- /dev/null +++ b/packages/collection-toolbar/README.md @@ -0,0 +1,25 @@ +# Collection Toolbar + +![npm (scoped)](https://img.shields.io/npm/v/@leafygreen-ui/collection-toolbar.svg) + +#### [View on MongoDB.design](https://www.mongodb.design/component/collection-toolbar/live-example/) + +## Installation + +### PNPM + +```shell +pnpm add @leafygreen-ui/collection-toolbar +``` + +### Yarn + +```shell +yarn add @leafygreen-ui/collection-toolbar +``` + +### NPM + +```shell +npm install @leafygreen-ui/collection-toolbar +``` diff --git a/packages/collection-toolbar/package.json b/packages/collection-toolbar/package.json new file mode 100644 index 0000000000..6415924d4d --- /dev/null +++ b/packages/collection-toolbar/package.json @@ -0,0 +1,56 @@ + +{ + "name": "@leafygreen-ui/collection-toolbar", + "version": "0.0.1", + "description": "LeafyGreen UI Kit Collection Toolbar", + "main": "./dist/umd/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "license": "Apache-2.0", + "exports": { + ".": { + "require": "./dist/umd/index.js", + "import": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts" + }, + "./testing": { + "require": "./dist/umd/testing/index.js", + "import": "./dist/esm/testing/index.js", + "types": "./dist/types/testing/index.d.ts" + } + }, + "scripts": { + "build": "lg-build bundle", + "tsc": "lg-build tsc", + "docs": "lg-build docs" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@leafygreen-ui/button": "workspace:^", + "@leafygreen-ui/compound-component": "workspace:^", + "@leafygreen-ui/emotion": "workspace:^", + "@leafygreen-ui/lib": "workspace:^", + "@leafygreen-ui/hooks": "workspace:^", + "@leafygreen-ui/icon": "workspace:^", + "@leafygreen-ui/icon-button": "workspace:^", + "@leafygreen-ui/menu": "workspace:^", + "@leafygreen-ui/pagination": "workspace:^", + "@leafygreen-ui/tokens": "workspace:^", + "@leafygreen-ui/tooltip": "workspace:^", + "@leafygreen-ui/typography": "workspace:^", + "@lg-tools/test-harnesses": "workspace:^" + }, + "peerDependencies": { + "@leafygreen-ui/leafygreen-provider": "workspace:^5.0.0 || ^4.0.0 || ^3.2.0" + }, + "homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/packages/collection-toolbar", + "repository": { + "type": "git", + "url": "https://github.com/mongodb/leafygreen-ui" + }, + "bugs": { + "url": "https://jira.mongodb.org/projects/LG/summary" + } +} diff --git a/packages/collection-toolbar/src/CollectionToolbar.stories.tsx b/packages/collection-toolbar/src/CollectionToolbar.stories.tsx new file mode 100644 index 0000000000..ed68de27a9 --- /dev/null +++ b/packages/collection-toolbar/src/CollectionToolbar.stories.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { storybookArgTypes, StoryMetaType } from '@lg-tools/storybook-utils'; +import { StoryFn } from '@storybook/react'; + +import { ButtonVariant, CollectionToolbar, Size, Variant } from '.'; + +const meta: StoryMetaType = { + title: 'Components/CollectionToolbar', + component: CollectionToolbar, + parameters: { + default: 'LiveExample', + generate: { + combineArgs: { + darkMode: [false, true], + size: Object.values(Size), + variant: Object.values(Variant), + }, + }, + docs: { + description: { + component: + 'CollectionToolbar is a component that displays a toolbar for a collection.', + }, + }, + }, + argTypes: { + darkMode: storybookArgTypes.darkMode, + size: { + control: 'select', + options: Object.values(Size), + }, + variant: { + control: 'select', + options: Object.values(Variant), + }, + }, +}; + +export default meta; + +export const LiveExample: StoryFn = props => ( + + Collection Title + + + Action + + + Action + + {}} + onForwardArrowClick={() => {}} + itemsPerPage={10} + numTotalItems={100} + /> + + + Menu Item + + + Menu Item + + + Menu Item + + + + +); + +export const Title: StoryFn = props => ( + + Collection Title + +); + +export const Actions: StoryFn = ({ + showToggleButton, + ...props +}) => { + return ( + + Collection Title + + + Action + + + Action + + + + ); +}; + +Actions.argTypes = { + showToggleButton: { + description: + 'Shows the toggle button. Only shows if the variant is collapsible.', + control: 'boolean', + defaultValue: false, + }, +}; diff --git a/packages/collection-toolbar/src/CollectionToolbar/CollectionToolbar.spec.tsx b/packages/collection-toolbar/src/CollectionToolbar/CollectionToolbar.spec.tsx new file mode 100644 index 0000000000..8138f6eb03 --- /dev/null +++ b/packages/collection-toolbar/src/CollectionToolbar/CollectionToolbar.spec.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { Variant } from '../shared.types'; +import { getTestUtils } from '../testing/getTestUtils'; + +import { CollectionToolbar } from '.'; + +describe('packages/collection-toolbar', () => { + test('renders correctly', () => { + render(); + const utils = getTestUtils(); + expect(utils.getCollectionToolbar()).toBeInTheDocument(); + }); + + test('applies className to the root element', () => { + render(); + const utils = getTestUtils(); + expect(utils.getCollectionToolbar()?.className).toContain('test-class'); + }); + + describe('variant: collapsible', () => { + test('renders title when variant is collapsible', () => { + render( + + Test Title + , + ); + expect(screen.getByText('Test Title')).toBeInTheDocument(); + }); + + test('does not render title when variant is not collapsible', () => { + render( + + Test Title + , + ); + expect(screen.queryByText('Test Title')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/collection-toolbar/src/CollectionToolbar/CollectionToolbar.styles.ts b/packages/collection-toolbar/src/CollectionToolbar/CollectionToolbar.styles.ts new file mode 100644 index 0000000000..91a70d0464 --- /dev/null +++ b/packages/collection-toolbar/src/CollectionToolbar/CollectionToolbar.styles.ts @@ -0,0 +1,20 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { spacing } from '@leafygreen-ui/tokens'; + +import { Size, Variant } from '../shared.types'; + +export const baseStyles = css` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: ${spacing[200]}px; +`; + +export const getCollectionToolbarStyles = ({ + className, +}: { + size?: Size; + variant?: Variant; + className?: string; +}) => cx(baseStyles, className); diff --git a/packages/collection-toolbar/src/CollectionToolbar/CollectionToolbar.tsx b/packages/collection-toolbar/src/CollectionToolbar/CollectionToolbar.tsx new file mode 100644 index 0000000000..1d6af3dfa2 --- /dev/null +++ b/packages/collection-toolbar/src/CollectionToolbar/CollectionToolbar.tsx @@ -0,0 +1,74 @@ +import React, { forwardRef } from 'react'; + +import { + CompoundComponent, + findChild, +} from '@leafygreen-ui/compound-component'; + +import { Actions, Title } from '../components'; +import { CollectionToolbarProvider } from '../Context/CollectionToolbarProvider'; +import { + CollectionToolbarSubComponentProperty, + Size, + Variant, +} from '../shared.types'; +import { getLgIds } from '../utils'; + +import { getCollectionToolbarStyles } from './CollectionToolbar.styles'; +import { CollectionToolbarProps } from './CollectionToolbar.types'; + +export const CollectionToolbar = CompoundComponent( + // eslint-disable-next-line react/display-name + forwardRef( + ( + { + size = Size.Default, + variant = Variant.Default, + className, + children, + 'data-lgid': dataLgId, + darkMode, + ...rest + }, + fwdRef, + ) => { + const lgIds = getLgIds(dataLgId); + + const title = findChild( + children, + CollectionToolbarSubComponentProperty.Title, + ); + + const actions = findChild( + children, + CollectionToolbarSubComponentProperty.Actions, + ); + + const showTitle = title && variant === Variant.Collapsible; + + return ( + +
+ {showTitle && title} + {actions} +
+
+ ); + }, + ), + { + displayName: 'CollectionToolbar', + Title, + Actions, + }, +); diff --git a/packages/collection-toolbar/src/CollectionToolbar/CollectionToolbar.types.ts b/packages/collection-toolbar/src/CollectionToolbar/CollectionToolbar.types.ts new file mode 100644 index 0000000000..6a41c699d3 --- /dev/null +++ b/packages/collection-toolbar/src/CollectionToolbar/CollectionToolbar.types.ts @@ -0,0 +1,23 @@ +import { ComponentPropsWithRef } from 'react'; + +import { DarkModeProps, LgIdProps } from '@leafygreen-ui/lib'; + +import { Size, Variant } from '../shared.types'; + +export interface CollectionToolbarProps + extends ComponentPropsWithRef<'div'>, + DarkModeProps, + LgIdProps { + /** + * The size of the CollectionToolbar and it's sub-components. + * + * @default `'default'` + */ + size?: typeof Size.Default | typeof Size.Small; + /** + * The variant of the CollectionToolbar. Determines the layout of the CollectionToolbar. + * + * @default `'default'` + */ + variant?: Variant; +} diff --git a/packages/collection-toolbar/src/CollectionToolbar/index.ts b/packages/collection-toolbar/src/CollectionToolbar/index.ts new file mode 100644 index 0000000000..0f12b57376 --- /dev/null +++ b/packages/collection-toolbar/src/CollectionToolbar/index.ts @@ -0,0 +1,2 @@ +export { CollectionToolbar } from './CollectionToolbar'; +export { type CollectionToolbarProps } from './CollectionToolbar.types'; diff --git a/packages/collection-toolbar/src/Context/CollectionToolbarProvider.spec.tsx b/packages/collection-toolbar/src/Context/CollectionToolbarProvider.spec.tsx new file mode 100644 index 0000000000..69c913b906 --- /dev/null +++ b/packages/collection-toolbar/src/Context/CollectionToolbarProvider.spec.tsx @@ -0,0 +1,316 @@ +import React from 'react'; + +import { act, isReact17, renderHook } from '@leafygreen-ui/testing-lib'; + +import { Size, Variant } from '../shared.types'; +import { getLgIds } from '../utils'; + +import { + CollectionToolbarProvider, + useCollectionToolbarContext, +} from './CollectionToolbarProvider'; + +describe('packages/collection-toolbar/Context/CollectionToolbarProvider', () => { + describe('useCollectionToolbarContext', () => { + test('throws error when used outside of CollectionToolbarProvider', () => { + /** + * The version of `renderHook` imported from "@testing-library/react-hooks", (used in React 17) + * has an error boundary, and doesn't throw errors as expected: + * https://github.com/testing-library/react-hooks-testing-library/blob/main/src/index.ts#L5 + */ + if (isReact17()) { + const { result } = renderHook(() => useCollectionToolbarContext()); + expect(result.error.message).toEqual( + 'useCollectionToolbarContext must be used within a CollectionToolbarProvider', + ); + } else { + expect(() => renderHook(() => useCollectionToolbarContext())).toThrow( + 'useCollectionToolbarContext must be used within a CollectionToolbarProvider', + ); + } + }); + + test('returns context values when used within CollectionToolbarProvider', () => { + const lgIds = getLgIds(); + const { result } = renderHook(() => useCollectionToolbarContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.size).toBe(Size.Default); + expect(result.current.variant).toBe(Variant.Default); + expect(result.current.lgIds).toEqual(lgIds); + expect(result.current.isCollapsed).toBe(false); + expect(result.current.onToggleCollapsed).toBeDefined(); + }); + }); + + describe('CollectionToolbarProvider', () => { + describe('size prop', () => { + test('provides default size value', () => { + const lgIds = getLgIds(); + const { result } = renderHook(() => useCollectionToolbarContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.size).toBe(Size.Default); + }); + + test('provides small size value', () => { + const lgIds = getLgIds(); + const { result } = renderHook(() => useCollectionToolbarContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.size).toBe(Size.Small); + }); + }); + + describe('variant prop', () => { + test('provides default variant value', () => { + const lgIds = getLgIds(); + const { result } = renderHook(() => useCollectionToolbarContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.variant).toBe(Variant.Default); + }); + + test('provides compact variant value', () => { + const lgIds = getLgIds(); + const { result } = renderHook(() => useCollectionToolbarContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.variant).toBe(Variant.Compact); + }); + + test('provides collapsible variant value', () => { + const lgIds = getLgIds(); + const { result } = renderHook(() => useCollectionToolbarContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.variant).toBe(Variant.Collapsible); + }); + }); + + describe('darkMode prop', () => { + test('provides darkMode value when true', () => { + const lgIds = getLgIds(); + const { result } = renderHook(() => useCollectionToolbarContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.darkMode).toBe(true); + }); + + test('provides darkMode value when false', () => { + const lgIds = getLgIds(); + const { result } = renderHook(() => useCollectionToolbarContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.darkMode).toBe(false); + }); + }); + + describe('lgIds prop', () => { + test('provides lgIds value', () => { + const lgIds = getLgIds(); + const { result } = renderHook(() => useCollectionToolbarContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.lgIds).toEqual(lgIds); + }); + + test('provides custom lgIds value', () => { + const customLgIds = getLgIds('lg-custom-root'); + const { result } = renderHook(() => useCollectionToolbarContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.lgIds).toEqual(customLgIds); + expect(result.current.lgIds.root).toBe('lg-custom-root'); + }); + }); + + describe('isCollapsed state', () => { + test('defaults to false when isCollapsed prop is not provided', () => { + const lgIds = getLgIds(); + const { result } = renderHook(() => useCollectionToolbarContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.isCollapsed).toBe(false); + }); + + test('uses isCollapsed prop value when provided', () => { + const lgIds = getLgIds(); + const { result } = renderHook(() => useCollectionToolbarContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.isCollapsed).toBe(true); + }); + }); + + describe('onToggleCollapsed', () => { + test('toggles isCollapsed state when called', () => { + const lgIds = getLgIds(); + const { result } = renderHook(() => useCollectionToolbarContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.isCollapsed).toBe(false); + + act(() => { + result.current.onToggleCollapsed?.( + {} as React.MouseEvent, + ); + }); + + expect(result.current.isCollapsed).toBe(true); + }); + + test('toggles isCollapsed state back to false', () => { + const lgIds = getLgIds(); + const { result } = renderHook(() => useCollectionToolbarContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.isCollapsed).toBe(true); + + act(() => { + result.current.onToggleCollapsed?.( + {} as React.MouseEvent, + ); + }); + + expect(result.current.isCollapsed).toBe(false); + }); + + test('calls onToggleCollapsed callback prop when toggle is called', () => { + const lgIds = getLgIds(); + const onToggleCollapsedMock = jest.fn(); + const mockEvent = {} as React.MouseEvent; + + const { result } = renderHook(() => useCollectionToolbarContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + act(() => { + result.current.onToggleCollapsed?.(mockEvent); + }); + + expect(onToggleCollapsedMock).toHaveBeenCalledTimes(1); + expect(onToggleCollapsedMock).toHaveBeenCalledWith(mockEvent); + }); + + test('handles multiple toggle calls correctly', () => { + const lgIds = getLgIds(); + const { result } = renderHook(() => useCollectionToolbarContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.isCollapsed).toBe(false); + + act(() => { + result.current.onToggleCollapsed?.( + {} as React.MouseEvent, + ); + }); + expect(result.current.isCollapsed).toBe(true); + + act(() => { + result.current.onToggleCollapsed?.( + {} as React.MouseEvent, + ); + }); + expect(result.current.isCollapsed).toBe(false); + + act(() => { + result.current.onToggleCollapsed?.( + {} as React.MouseEvent, + ); + }); + expect(result.current.isCollapsed).toBe(true); + }); + }); + }); +}); diff --git a/packages/collection-toolbar/src/Context/CollectionToolbarProvider.tsx b/packages/collection-toolbar/src/Context/CollectionToolbarProvider.tsx new file mode 100644 index 0000000000..9215fd7e9a --- /dev/null +++ b/packages/collection-toolbar/src/Context/CollectionToolbarProvider.tsx @@ -0,0 +1,102 @@ +import React, { + createContext, + MouseEventHandler, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; +import { DarkModeProps } from '@leafygreen-ui/lib'; + +import { Size, Variant } from '../shared.types'; +import { type GetLgIdsReturnType } from '../utils'; + +export interface CollectionToolbarContextProps extends DarkModeProps { + /** + * The size of the CollectionToolbar and it's sub-components. + * + * @default `'default'` + */ + size?: Size; + /** + * The variant of the CollectionToolbar. Determines the layout of the CollectionToolbar. + * + * @default `'default'` + */ + variant?: Variant; + /** + * LGIDs for CollectionToolbar components + */ + lgIds: GetLgIdsReturnType; + /** + * Whether the CollectionToolbar is collapsed. + */ + isCollapsed?: boolean; + /** + * Function to toggle the collapsed state of the CollectionToolbar. + */ + onToggleCollapsed?: MouseEventHandler; +} + +export const CollectionToolbarContext = + createContext(null); + +export const useCollectionToolbarContext = () => { + const context = useContext(CollectionToolbarContext); + + if (!context) + throw new Error( + 'useCollectionToolbarContext must be used within a CollectionToolbarProvider', + ); + + return context; +}; + +export const CollectionToolbarProvider = ({ + children, + darkMode, + size, + variant, + lgIds, + isCollapsed: isCollapsedProp = false, + onToggleCollapsed: onToggleCollapsedProp, +}: PropsWithChildren) => { + const [isCollapsed, setIsCollapsed] = useState(isCollapsedProp); + + // Sync internal state when controlled prop changes + useEffect(() => { + setIsCollapsed(isCollapsedProp); + }, [isCollapsedProp]); + + const handleToggleCollapsed: MouseEventHandler = + useCallback( + event => { + setIsCollapsed(curr => !curr); + onToggleCollapsedProp?.(event); + }, + [onToggleCollapsedProp], + ); + + const collectionToolbarProviderData = useMemo(() => { + return { + size, + variant, + lgIds, + isCollapsed, + onToggleCollapsed: handleToggleCollapsed, + darkMode, + }; + }, [size, variant, lgIds, isCollapsed, handleToggleCollapsed, darkMode]); + + return ( + + + {children} + + + ); +}; diff --git a/packages/collection-toolbar/src/components/Actions/Action.styles.ts b/packages/collection-toolbar/src/components/Actions/Action.styles.ts new file mode 100644 index 0000000000..d8b1699290 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Action.styles.ts @@ -0,0 +1,12 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { spacing } from '@leafygreen-ui/tokens'; + +export const baseStyles = css` + display: flex; + flex-direction: row; + align-items: center; + gap: ${spacing[100]}px; +`; + +export const getActionStyles = ({ className }: { className?: string }) => + cx(baseStyles, className); diff --git a/packages/collection-toolbar/src/components/Actions/Actions.spec.tsx b/packages/collection-toolbar/src/components/Actions/Actions.spec.tsx new file mode 100644 index 0000000000..159dbf8141 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Actions.spec.tsx @@ -0,0 +1,323 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { consoleOnce } from '@leafygreen-ui/lib'; + +import { CollectionToolbarProvider } from '../../Context/CollectionToolbarProvider'; +import { + CollectionToolbarActionsSubComponentProperty, + Size, + Variant, +} from '../../shared.types'; +import { getLgIds } from '../../utils'; + +import { Actions } from './Actions'; + +jest.mock('@leafygreen-ui/lib', () => ({ + ...jest.requireActual('@leafygreen-ui/lib'), + consoleOnce: { + error: jest.fn(), + warn: jest.fn(), + log: jest.fn(), + }, +})); + +const lgIds = getLgIds(); + +const renderActions = ({ + children, + isCollapsed = false, + variant = Variant.Default, + size = Size.Default, + ...props +}: { + children?: React.ReactNode; + variant?: Variant; + size?: Size; + isCollapsed?: boolean; +} & React.ComponentProps = {}) => { + return render( + + {children} + , + ); +}; + +describe('packages/collection-toolbar/components/Actions', () => { + describe('rendering', () => { + test('renders container with correct data-testid and data-lgid', () => { + renderActions(); + const actionsContainer = screen.getByTestId(lgIds.actions); + expect(actionsContainer).toBeInTheDocument(); + expect(actionsContainer).toHaveAttribute('data-lgid', lgIds.actions); + }); + + test('renders children Button components', () => { + renderActions({ + children: ( + <> + Button 1 + Button 2 + + ), + }); + expect(screen.getByText('Button 1')).toBeInTheDocument(); + expect(screen.getByText('Button 2')).toBeInTheDocument(); + }); + + test('limits rendered buttons to maximum of 2 and logs console error', () => { + renderActions({ + children: ( + <> + Button 1 + Button 2 + Button 3 + + ), + }); + + expect(screen.getByText('Button 1')).toBeInTheDocument(); + expect(screen.getByText('Button 2')).toBeInTheDocument(); + expect(screen.queryByText('Button 3')).not.toBeInTheDocument(); + + expect(consoleOnce.error).toHaveBeenCalledWith( + 'CollectionToolbarActions can only have up to 2 buttons', + ); + }); + }); + + describe('Pagination visibility', () => { + const paginationProps = { + currentPage: 1, + numTotalItems: 100, + itemsPerPage: 10, + onBackArrowClick: jest.fn(), + onForwardArrowClick: jest.fn(), + }; + + test('renders Pagination when variant is "default" and Pagination child is provided', () => { + renderActions({ + variant: Variant.Default, + children: , + }); + expect(screen.getByLabelText('Next page')).toBeInTheDocument(); + }); + + test('does not render Pagination when variant is "compact"', () => { + renderActions({ + variant: Variant.Compact, + children: , + }); + expect(screen.queryByLabelText('Next page')).not.toBeInTheDocument(); + }); + + test('does not render Pagination when variant is "collapsible"', () => { + renderActions({ + variant: Variant.Collapsible, + children: , + }); + expect(screen.queryByLabelText('Next page')).not.toBeInTheDocument(); + }); + }); + + describe('Menu visibility', () => { + test('renders Menu when variant is "default" and Menu child is provided', () => { + renderActions({ + variant: Variant.Default, + children: ( + + Item 1 + + ), + }); + expect(screen.getByLabelText('More options')).toBeInTheDocument(); + }); + + test('renders Menu when variant is "compact" and Menu child is provided', () => { + renderActions({ + variant: Variant.Compact, + children: ( + + Item 1 + + ), + }); + expect(screen.getByLabelText('More options')).toBeInTheDocument(); + }); + + test('does not render Menu when variant is "collapsible"', () => { + renderActions({ + variant: Variant.Collapsible, + children: ( + + Item 1 + + ), + }); + expect(screen.queryByLabelText('More options')).not.toBeInTheDocument(); + }); + }); + + describe('Toggle button (Collapsible variant)', () => { + test('renders toggle IconButton when variant is "collapsible" and showToggleButton is true', () => { + renderActions({ variant: Variant.Collapsible, showToggleButton: true }); + expect(screen.getByLabelText('Toggle collapse')).toBeInTheDocument(); + }); + + test('does not render toggle IconButton when variant is "collapsible" and showToggleButton is false', () => { + renderActions({ variant: Variant.Collapsible, showToggleButton: false }); + expect( + screen.queryByLabelText('Toggle collapse'), + ).not.toBeInTheDocument(); + }); + + test('does not render toggle IconButton when variant is "default"', () => { + renderActions({ variant: Variant.Default }); + expect( + screen.queryByLabelText('Toggle collapse'), + ).not.toBeInTheDocument(); + }); + + test('does not render toggle IconButton when showToggleButton is false', () => { + renderActions({ variant: Variant.Collapsible, showToggleButton: false }); + expect( + screen.queryByLabelText('Toggle collapse'), + ).not.toBeInTheDocument(); + }); + + test('toggle IconButton has aria-label "Toggle collapse"', () => { + renderActions({ variant: Variant.Collapsible, showToggleButton: true }); + const toggleButton = screen.getByLabelText('Toggle collapse'); + expect(toggleButton).toHaveAttribute('aria-label', 'Toggle collapse'); + }); + + test('toggle IconButton has aria-expanded "false" when isCollapsed is true', () => { + renderActions({ + variant: Variant.Collapsible, + showToggleButton: true, + isCollapsed: true, + }); + const toggleButton = screen.getByLabelText('Toggle collapse'); + expect(toggleButton).toHaveAttribute('aria-expanded', 'false'); + }); + + test('toggle IconButton has aria-expanded "true" when isCollapsed is false', () => { + renderActions({ + variant: Variant.Collapsible, + showToggleButton: true, + isCollapsed: false, + }); + const toggleButton = screen.getByLabelText('Toggle collapse'); + expect(toggleButton).toHaveAttribute('aria-expanded', 'true'); + }); + + test('does not render toggle IconButton when variant is "compact"', () => { + renderActions({ variant: Variant.Compact }); + expect( + screen.queryByLabelText('Toggle collapse'), + ).not.toBeInTheDocument(); + }); + + test('toggle button has aria-label "Toggle collapse"', () => { + renderActions({ variant: Variant.Collapsible, showToggleButton: true }); + const toggleButton = screen.getByLabelText('Toggle collapse'); + expect(toggleButton).toHaveAttribute('aria-label', 'Toggle collapse'); + }); + + test('tooltip shows "Hide filters" when isCollapsed is false', async () => { + renderActions({ + variant: Variant.Collapsible, + showToggleButton: true, + isCollapsed: false, + }); + const toggleButton = screen.getByLabelText('Toggle collapse'); + + userEvent.hover(toggleButton); + + await waitFor(() => { + expect(screen.getByText('Hide filters')).toBeInTheDocument(); + }); + }); + + test('tooltip shows "Show filters" when isCollapsed is true', async () => { + renderActions({ + variant: Variant.Collapsible, + showToggleButton: true, + isCollapsed: true, + }); + const toggleButton = screen.getByLabelText('Toggle collapse'); + + userEvent.hover(toggleButton); + + await waitFor(() => { + expect(screen.getByText('Show filters')).toBeInTheDocument(); + }); + }); + + test('clicking toggle button toggles the collapsed state', async () => { + renderActions({ + variant: Variant.Collapsible, + showToggleButton: true, + isCollapsed: false, + }); + const toggleButton = screen.getByLabelText('Toggle collapse'); + + userEvent.hover(toggleButton); + await waitFor(() => { + expect(screen.getByText('Hide filters')).toBeInTheDocument(); + }); + + userEvent.click(toggleButton); + + await waitFor(() => { + expect(screen.getByText('Show filters')).toBeInTheDocument(); + }); + }); + }); + + describe('props & styling', () => { + test('applies className prop to container', () => { + renderActions({ className: 'custom-class' }); + const actionsContainer = screen.getByTestId(lgIds.actions); + expect(actionsContainer).toHaveClass('custom-class'); + }); + + test('spreads additional props to container element', () => { + renderActions({ + 'aria-label': 'Action buttons', + id: 'custom-id', + } as React.ComponentProps); + + const actionsContainer = screen.getByTestId(lgIds.actions); + expect(actionsContainer).toHaveAttribute('aria-label', 'Action buttons'); + expect(actionsContainer).toHaveAttribute('id', 'custom-id'); + }); + }); + + describe('compound component', () => { + test('has the correct static property for compound component identification', () => { + expect(Actions[CollectionToolbarActionsSubComponentProperty.Button]).toBe( + undefined, + ); + // Actions uses CollectionToolbarSubComponentProperty.Actions as key + }); + + test('exposes Button as a static property', () => { + expect(Actions.Button).toBeDefined(); + }); + + test('exposes Pagination as a static property', () => { + expect(Actions.Pagination).toBeDefined(); + }); + + test('exposes Menu as a static property', () => { + expect(Actions.Menu).toBeDefined(); + }); + }); +}); diff --git a/packages/collection-toolbar/src/components/Actions/Actions.tsx b/packages/collection-toolbar/src/components/Actions/Actions.tsx new file mode 100644 index 0000000000..1c83e629f2 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Actions.tsx @@ -0,0 +1,111 @@ +import React, { forwardRef } from 'react'; + +import { + CompoundSubComponent, + findChild, + findChildren, +} from '@leafygreen-ui/compound-component'; +import { Icon } from '@leafygreen-ui/icon'; +import { IconButton } from '@leafygreen-ui/icon-button'; +import { consoleOnce } from '@leafygreen-ui/lib'; +import { Justify, Tooltip } from '@leafygreen-ui/tooltip'; + +import { useCollectionToolbarContext } from '../../Context/CollectionToolbarProvider'; +import { + CollectionToolbarActionsSubComponentProperty, + CollectionToolbarSubComponentProperty, + Variant, +} from '../../shared.types'; + +import { getActionStyles } from './Action.styles'; +import { ActionsProps } from './Actions.types'; +import { Button } from './Button'; +import { Menu } from './Menu'; +import { Pagination } from './Pagination'; + +export const Actions = CompoundSubComponent( + // eslint-disable-next-line react/display-name + forwardRef( + ( + { + ariaControls, + children, + className, + showToggleButton: showToggleButtonProp = false, + ...rest + }, + fwdRef, + ) => { + const { lgIds, variant, onToggleCollapsed, isCollapsed } = + useCollectionToolbarContext(); + + const showToggleButton = + showToggleButtonProp && variant === Variant.Collapsible; + + const Buttons = findChildren( + children, + CollectionToolbarActionsSubComponentProperty.Button, + ); + + const pagination = findChild( + children, + CollectionToolbarActionsSubComponentProperty.Pagination, + ); + + const menu = findChild( + children, + CollectionToolbarActionsSubComponentProperty.Menu, + ); + + const showPagination = pagination && variant === Variant.Default; + const showMenu = menu && variant !== Variant.Collapsible; + + if (Buttons.length > 2) { + consoleOnce.error( + 'CollectionToolbarActions can only have up to 2 buttons', + ); + } + + const PrimaryButtons = Buttons.slice(0, 2); + + return ( +
+ {PrimaryButtons} + {showPagination && pagination} + {showMenu && menu} + {showToggleButton && ( + + + + } + > + {isCollapsed ? 'Show filters' : 'Hide filters'} + + )} +
+ ); + }, + ), + { + displayName: 'Actions', + key: CollectionToolbarSubComponentProperty.Actions, + Button, + Pagination, + Menu, + }, +); diff --git a/packages/collection-toolbar/src/components/Actions/Actions.types.ts b/packages/collection-toolbar/src/components/Actions/Actions.types.ts new file mode 100644 index 0000000000..aa5bae8f85 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Actions.types.ts @@ -0,0 +1,16 @@ +import { ComponentPropsWithRef } from 'react'; + +export interface ActionsProps extends ComponentPropsWithRef<'div'> { + /** + * Determines whether to show the toggle button. + * Only shows if the variant is collapsible. + * @default false + */ + showToggleButton?: boolean; + + /** + * The ID of the element that should be controlled by the toggle button. + * @default undefined + */ + ariaControls?: string; +} diff --git a/packages/collection-toolbar/src/components/Actions/Button/Button.spec.tsx b/packages/collection-toolbar/src/components/Actions/Button/Button.spec.tsx new file mode 100644 index 0000000000..4217c14eff --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Button/Button.spec.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { CollectionToolbarProvider } from '../../../Context/CollectionToolbarProvider'; +import { + CollectionToolbarActionsSubComponentProperty, + Size, +} from '../../../shared.types'; +import { getLgIds } from '../../../utils'; + +import { Button, ButtonVariant } from '.'; + +const lgIds = getLgIds(); + +const renderButton = ({ + children = 'Test Button', + size = Size.Default, + ...props +}: { + children?: React.ReactNode; + size?: Size; +} & React.ComponentProps = {}) => { + return render( + + + , + ); +}; + +describe('packages/collection-toolbar/components/Actions/Button', () => { + describe('rendering', () => { + test('renders as a Button element', () => { + renderButton(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + test('renders children content', () => { + renderButton({ children: 'Click Me' }); + expect(screen.getByText('Click Me')).toBeInTheDocument(); + }); + }); + + describe('context integration', () => { + test('uses size from CollectionToolbarContext when size is "default"', () => { + renderButton({ size: Size.Default }); + const button = screen.getByRole('button'); + // Button should render with default size styling + expect(button).toBeInTheDocument(); + }); + + test('uses size from CollectionToolbarContext when size is "small"', () => { + renderButton({ size: Size.Small }); + const button = screen.getByRole('button'); + // Button should render with small size styling + expect(button).toBeInTheDocument(); + }); + }); + + describe('props', () => { + test('spreads additional props to Button', () => { + renderButton({ + 'aria-label': 'Custom label', + id: 'custom-id', + } as React.ComponentProps); + + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-label', 'Custom label'); + expect(button).toHaveAttribute('id', 'custom-id'); + }); + + test('forwards onClick handler', async () => { + const handleClick = jest.fn(); + renderButton({ onClick: handleClick }); + + const button = screen.getByRole('button'); + await userEvent.click(button); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + test('applies className prop', () => { + renderButton({ className: 'custom-class' }); + const button = screen.getByRole('button'); + expect(button).toHaveClass('custom-class'); + }); + + test('supports disabled prop', () => { + renderButton({ disabled: true }); + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + }); + + test('supports variant prop', () => { + renderButton({ variant: ButtonVariant.Primary }); + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + }); + }); + + describe('compound component', () => { + test('has the correct static property for compound component identification', () => { + expect(Button[CollectionToolbarActionsSubComponentProperty.Button]).toBe( + true, + ); + }); + }); +}); diff --git a/packages/collection-toolbar/src/components/Actions/Button/Button.tsx b/packages/collection-toolbar/src/components/Actions/Button/Button.tsx new file mode 100644 index 0000000000..9320daf2eb --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Button/Button.tsx @@ -0,0 +1,21 @@ +import React, { forwardRef } from 'react'; + +import { Button as LGButton } from '@leafygreen-ui/button'; +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; + +import { useCollectionToolbarContext } from '../../../Context/CollectionToolbarProvider'; +import { CollectionToolbarActionsSubComponentProperty } from '../../../shared.types'; + +import { ButtonProps } from './Button.types'; + +export const Button = CompoundSubComponent( + // eslint-disable-next-line react/display-name + forwardRef(({ ...props }, fwdRef) => { + const { size } = useCollectionToolbarContext(); + return ; + }), + { + displayName: 'Button', + key: CollectionToolbarActionsSubComponentProperty.Button, + }, +); diff --git a/packages/collection-toolbar/src/components/Actions/Button/Button.types.ts b/packages/collection-toolbar/src/components/Actions/Button/Button.types.ts new file mode 100644 index 0000000000..9fde065e15 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Button/Button.types.ts @@ -0,0 +1,7 @@ +import { + BaseButtonProps, + Variant as BaseButtonVariant, +} from '@leafygreen-ui/button'; + +export type ButtonProps = Omit; +export const ButtonVariant = BaseButtonVariant; diff --git a/packages/collection-toolbar/src/components/Actions/Button/index.ts b/packages/collection-toolbar/src/components/Actions/Button/index.ts new file mode 100644 index 0000000000..ffa8738b41 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Button/index.ts @@ -0,0 +1,3 @@ +export { Button } from './Button'; +export type { ButtonProps } from './Button.types'; +export { ButtonVariant } from './Button.types'; diff --git a/packages/collection-toolbar/src/components/Actions/Menu/Menu.spec.tsx b/packages/collection-toolbar/src/components/Actions/Menu/Menu.spec.tsx new file mode 100644 index 0000000000..3af5c00569 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Menu/Menu.spec.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { CollectionToolbarProvider } from '../../../Context/CollectionToolbarProvider'; +import { CollectionToolbarActionsSubComponentProperty } from '../../../shared.types'; +import { getLgIds } from '../../../utils'; + +import { Menu } from './Menu'; + +const renderMenu = ({ + children = Test Item, + ...props +}: Partial> & { + children?: React.ReactNode; +} = {}) => { + const lgIds = getLgIds(); + return render( + + {children} + , + ); +}; + +describe('packages/collection-toolbar/components/Actions/Menu', () => { + describe('rendering', () => { + test('renders Menu with IconButton trigger', () => { + renderMenu(); + expect(screen.getByLabelText('More options')).toBeInTheDocument(); + }); + + test('trigger IconButton has Ellipsis icon', () => { + renderMenu(); + const trigger = screen.getByLabelText('More options'); + expect(trigger.querySelector('svg')).toBeInTheDocument(); + }); + + test('trigger IconButton has aria-label "More options"', () => { + renderMenu(); + const trigger = screen.getByLabelText('More options'); + expect(trigger).toHaveAttribute('aria-label', 'More options'); + }); + }); + + describe('interaction', () => { + test('opens menu when trigger is clicked', async () => { + renderMenu({ + children: Menu Item 1, + }); + + const trigger = screen.getByLabelText('More options'); + await userEvent.click(trigger); + + await waitFor(() => { + expect(screen.getByText('Menu Item 1')).toBeVisible(); + }); + }); + + test('renders MenuItem children when menu is open', async () => { + renderMenu({ + children: ( + <> + Item 1 + Item 2 + Item 3 + + ), + }); + + const trigger = screen.getByLabelText('More options'); + await userEvent.click(trigger); + + await waitFor(() => { + expect(screen.getByText('Item 1')).toBeVisible(); + expect(screen.getByText('Item 2')).toBeVisible(); + expect(screen.getByText('Item 3')).toBeVisible(); + }); + }); + }); + + describe('compound component', () => { + test('has the correct static property for compound component identification', () => { + expect(Menu[CollectionToolbarActionsSubComponentProperty.Menu]).toBe( + true, + ); + }); + + test('exposes MenuItem as a static property', () => { + expect(Menu.MenuItem).toBeDefined(); + }); + }); +}); diff --git a/packages/collection-toolbar/src/components/Actions/Menu/Menu.tsx b/packages/collection-toolbar/src/components/Actions/Menu/Menu.tsx new file mode 100644 index 0000000000..f067011acd --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Menu/Menu.tsx @@ -0,0 +1,40 @@ +import React, { forwardRef } from 'react'; + +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; +import { Icon } from '@leafygreen-ui/icon'; +import { IconButton } from '@leafygreen-ui/icon-button'; +import { Menu as LGMenu } from '@leafygreen-ui/menu'; + +import { useCollectionToolbarContext } from '../../../Context/CollectionToolbarProvider'; +import { CollectionToolbarActionsSubComponentProperty } from '../../../shared.types'; + +import { MenuProps } from './Menu.types'; +import { MenuItem } from './MenuItem'; + +export const Menu = CompoundSubComponent( + // eslint-disable-next-line react/display-name + forwardRef(({ children, ...props }, fwdRef) => { + const { lgIds } = useCollectionToolbarContext(); + + return ( + + + + } + > + {children} + + ); + }), + { + displayName: 'Menu', + key: CollectionToolbarActionsSubComponentProperty.Menu, + MenuItem, + }, +); diff --git a/packages/collection-toolbar/src/components/Actions/Menu/Menu.types.ts b/packages/collection-toolbar/src/components/Actions/Menu/Menu.types.ts new file mode 100644 index 0000000000..d70ebb2f99 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Menu/Menu.types.ts @@ -0,0 +1,3 @@ +import { MenuProps as LGMenuProps } from '@leafygreen-ui/menu'; + +export type MenuProps = Omit; diff --git a/packages/collection-toolbar/src/components/Actions/Menu/MenuItem/MenuItem.spec.tsx b/packages/collection-toolbar/src/components/Actions/Menu/MenuItem/MenuItem.spec.tsx new file mode 100644 index 0000000000..2bb59251fd --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Menu/MenuItem/MenuItem.spec.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { CollectionToolbarProvider } from '../../../../Context/CollectionToolbarProvider'; +import { CollectionToolbarActionsSubComponentProperty } from '../../../../shared.types'; +import { getLgIds } from '../../../../utils'; + +import { MenuItem } from './MenuItem'; +import { MenuItemProps } from './MenuItem.types'; + +const renderMenuItem = (props: MenuItemProps = {}) => { + const lgIds = getLgIds(); + return render( + + + , + ); +}; + +describe('packages/collection-toolbar/components/Actions/Menu/MenuItem', () => { + describe('rendering', () => { + test('renders as a menu item', () => { + renderMenuItem(); + expect(screen.getByRole('menuitem')).toBeInTheDocument(); + }); + + test('renders children content', () => { + renderMenuItem({ children: 'Click Me' }); + expect(screen.getByText('Click Me')).toBeInTheDocument(); + }); + }); + + describe('props', () => { + test('spreads additional props to LGMenuItem', () => { + renderMenuItem({ + 'aria-label': 'Custom label', + } as React.ComponentProps); + + const menuItem = screen.getByRole('menuitem'); + expect(menuItem).toHaveAttribute('aria-label', 'Custom label'); + }); + + test('forwards onClick handler', async () => { + const handleClick = jest.fn(); + renderMenuItem({ onClick: handleClick }); + + const menuItem = screen.getByRole('menuitem'); + await userEvent.click(menuItem); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + test('applies className prop', () => { + renderMenuItem({ className: 'custom-class' }); + const menuItem = screen.getByRole('menuitem'); + expect(menuItem).toHaveClass('custom-class'); + }); + + test('supports disabled prop', () => { + renderMenuItem({ disabled: true }); + const menuItem = screen.getByRole('menuitem'); + expect(menuItem).toHaveAttribute('aria-disabled', 'true'); + }); + + test('does not call onClick when disabled', async () => { + const handleClick = jest.fn(); + renderMenuItem({ disabled: true, onClick: handleClick }); + + const menuItem = screen.getByRole('menuitem'); + await userEvent.click(menuItem); + + expect(handleClick).not.toHaveBeenCalled(); + }); + + test('supports description prop', () => { + renderMenuItem({ description: 'Item description' }); + expect(screen.getByText('Item description')).toBeInTheDocument(); + }); + }); + + describe('compound component', () => { + test('has the correct static property for compound component identification', () => { + expect( + MenuItem[CollectionToolbarActionsSubComponentProperty.MenuItem], + ).toBe(true); + }); + }); +}); diff --git a/packages/collection-toolbar/src/components/Actions/Menu/MenuItem/MenuItem.tsx b/packages/collection-toolbar/src/components/Actions/Menu/MenuItem/MenuItem.tsx new file mode 100644 index 0000000000..f75df7c5af --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Menu/MenuItem/MenuItem.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; +import { useIdAllocator } from '@leafygreen-ui/hooks'; +import { + type InternalMenuItemProps, + MenuItem as LGMenuItem, +} from '@leafygreen-ui/menu'; + +import { useCollectionToolbarContext } from '../../../../Context/CollectionToolbarProvider'; +import { CollectionToolbarActionsSubComponentProperty } from '../../../../shared.types'; + +export const MenuItem = CompoundSubComponent( + ({ children, ...props }: InternalMenuItemProps) => { + const { lgIds } = useCollectionToolbarContext(); + const menuItemId = useIdAllocator({ + prefix: lgIds.menuItem, + }); + + return ( + + {children} + + ); + }, + { + displayName: 'MenuItem', + key: CollectionToolbarActionsSubComponentProperty.MenuItem, + }, +); diff --git a/packages/collection-toolbar/src/components/Actions/Menu/MenuItem/MenuItem.types.ts b/packages/collection-toolbar/src/components/Actions/Menu/MenuItem/MenuItem.types.ts new file mode 100644 index 0000000000..7c0ec8a252 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Menu/MenuItem/MenuItem.types.ts @@ -0,0 +1,3 @@ +import { MenuItemProps as LGMenuItemProps } from '@leafygreen-ui/menu'; + +export type MenuItemProps = LGMenuItemProps; diff --git a/packages/collection-toolbar/src/components/Actions/Menu/MenuItem/index.ts b/packages/collection-toolbar/src/components/Actions/Menu/MenuItem/index.ts new file mode 100644 index 0000000000..bfb8437f4f --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Menu/MenuItem/index.ts @@ -0,0 +1,2 @@ +export { MenuItem } from './MenuItem'; +export type { MenuItemProps } from './MenuItem.types'; diff --git a/packages/collection-toolbar/src/components/Actions/Menu/index.ts b/packages/collection-toolbar/src/components/Actions/Menu/index.ts new file mode 100644 index 0000000000..d6de4ecb5b --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Menu/index.ts @@ -0,0 +1,3 @@ +export { Menu } from './Menu'; +export type { MenuProps } from './Menu.types'; +export { MenuItem, type MenuItemProps } from './MenuItem'; diff --git a/packages/collection-toolbar/src/components/Actions/Pagination/Pagination.spec.tsx b/packages/collection-toolbar/src/components/Actions/Pagination/Pagination.spec.tsx new file mode 100644 index 0000000000..11b5bd9cf8 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Pagination/Pagination.spec.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { CollectionToolbarProvider } from '../../../Context/CollectionToolbarProvider'; +import { CollectionToolbarActionsSubComponentProperty } from '../../../shared.types'; +import { getTestUtils } from '../../../testing/getTestUtils'; +import { getLgIds } from '../../../utils'; + +import { Pagination } from './Pagination'; +import { PaginationProps } from './Pagination.types'; + +const defaultProps = { + currentPage: 1, + numTotalItems: 100, + itemsPerPage: 10, + onBackArrowClick: jest.fn(), + onForwardArrowClick: jest.fn(), +}; + +const renderPagination = (props?: Partial) => { + const lgIds = getLgIds(); + return render( + + + , + ); +}; + +describe('packages/collection-toolbar/components/Actions/Pagination', () => { + describe('rendering', () => { + test('renders PaginationNavigation component', () => { + renderPagination(); + expect(screen.getByLabelText('Next page')).toBeInTheDocument(); + expect(screen.getByLabelText('Previous page')).toBeInTheDocument(); + }); + + test('displays current page information', () => { + renderPagination({ currentPage: 3 }); + expect(screen.getByText('3 of 10')).toBeInTheDocument(); + }); + }); + + describe('props', () => { + test('applies className prop with styles', () => { + renderPagination({ className: 'custom-class' }); + const { getPagination } = getTestUtils(); + + expect(getPagination()).toHaveClass('custom-class'); + }); + + test('forwards currentPage prop', () => { + renderPagination({ currentPage: 5 }); + expect(screen.getByText('5 of 10')).toBeInTheDocument(); + }); + + test('forwards numTotalItems prop', () => { + renderPagination({ numTotalItems: 50, itemsPerPage: 10 }); + // With 50 items and 10 per page, there should be 5 pages + expect(screen.getByText('1 of 5')).toBeInTheDocument(); + }); + }); + + describe('callbacks', () => { + test('calls onBackArrowClick when back arrow is clicked', async () => { + const handleBackClick = jest.fn(); + renderPagination({ + currentPage: 2, + onBackArrowClick: handleBackClick, + }); + + const backButton = screen.getByLabelText('Previous page'); + await userEvent.click(backButton); + + expect(handleBackClick).toHaveBeenCalledTimes(1); + }); + + test('calls onForwardArrowClick when forward arrow is clicked', async () => { + const handleForwardClick = jest.fn(); + renderPagination({ + currentPage: 1, + onForwardArrowClick: handleForwardClick, + }); + + const forwardButton = screen.getByLabelText('Next page'); + await userEvent.click(forwardButton); + + expect(handleForwardClick).toHaveBeenCalledTimes(1); + }); + + test('back arrow is disabled on first page', () => { + renderPagination({ currentPage: 1 }); + const backButton = screen.getByLabelText('Previous page'); + expect(backButton).toHaveAttribute('aria-disabled', 'true'); + }); + + test('forward arrow is disabled on last page', () => { + renderPagination({ + currentPage: 10, + numTotalItems: 100, + itemsPerPage: 10, + }); + const forwardButton = screen.getByLabelText('Next page'); + expect(forwardButton).toHaveAttribute('aria-disabled', 'true'); + }); + }); + + describe('compound component', () => { + test('has the correct static property for compound component identification', () => { + expect( + Pagination[CollectionToolbarActionsSubComponentProperty.Pagination], + ).toBe(true); + }); + }); +}); diff --git a/packages/collection-toolbar/src/components/Actions/Pagination/Pagination.styles.ts b/packages/collection-toolbar/src/components/Actions/Pagination/Pagination.styles.ts new file mode 100644 index 0000000000..cfd00bace0 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Pagination/Pagination.styles.ts @@ -0,0 +1,9 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { spacing } from '@leafygreen-ui/tokens'; + +export const baseStyles = css` + margin-left: ${spacing[500]}px; +`; + +export const getPaginationStyles = ({ className }: { className?: string }) => + cx(baseStyles, className); diff --git a/packages/collection-toolbar/src/components/Actions/Pagination/Pagination.tsx b/packages/collection-toolbar/src/components/Actions/Pagination/Pagination.tsx new file mode 100644 index 0000000000..fc06e1c0c5 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Pagination/Pagination.tsx @@ -0,0 +1,33 @@ +import React, { forwardRef } from 'react'; + +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; +import { PaginationNavigation } from '@leafygreen-ui/pagination'; + +import { useCollectionToolbarContext } from '../../../Context/CollectionToolbarProvider'; +import { CollectionToolbarActionsSubComponentProperty } from '../../../shared.types'; + +import { getPaginationStyles } from './Pagination.styles'; +import { PaginationProps } from './Pagination.types'; + +export const Pagination = CompoundSubComponent( + // eslint-disable-next-line react/display-name + forwardRef( + ({ className, ...props }, fwdRef) => { + const { lgIds } = useCollectionToolbarContext(); + return ( +
+ +
+ ); + }, + ), + { + displayName: 'Pagination', + key: CollectionToolbarActionsSubComponentProperty.Pagination, + }, +); diff --git a/packages/collection-toolbar/src/components/Actions/Pagination/Pagination.types.tsx b/packages/collection-toolbar/src/components/Actions/Pagination/Pagination.types.tsx new file mode 100644 index 0000000000..2d0f44576c --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Pagination/Pagination.types.tsx @@ -0,0 +1,3 @@ +import { NavigationProps } from '@leafygreen-ui/pagination'; + +export type PaginationProps = NavigationProps; diff --git a/packages/collection-toolbar/src/components/Actions/Pagination/index.ts b/packages/collection-toolbar/src/components/Actions/Pagination/index.ts new file mode 100644 index 0000000000..b740e5d87f --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Pagination/index.ts @@ -0,0 +1,2 @@ +export { Pagination } from './Pagination'; +export { type PaginationProps } from './Pagination.types'; diff --git a/packages/collection-toolbar/src/components/Actions/index.ts b/packages/collection-toolbar/src/components/Actions/index.ts new file mode 100644 index 0000000000..be7f164578 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/index.ts @@ -0,0 +1,5 @@ +export { Actions } from './Actions'; +export type { ActionsProps } from './Actions.types'; +export { Button, type ButtonProps, ButtonVariant } from './Button'; +export { Menu, MenuItem, type MenuItemProps, type MenuProps } from './Menu'; +export { Pagination, type PaginationProps } from './Pagination'; diff --git a/packages/collection-toolbar/src/components/Title/Title.spec.tsx b/packages/collection-toolbar/src/components/Title/Title.spec.tsx new file mode 100644 index 0000000000..c5c98ffd1c --- /dev/null +++ b/packages/collection-toolbar/src/components/Title/Title.spec.tsx @@ -0,0 +1,104 @@ +import React, { createRef } from 'react'; +import { render, screen } from '@testing-library/react'; + +import { CollectionToolbarProvider } from '../../Context/CollectionToolbarProvider'; +import { CollectionToolbarSubComponentProperty } from '../../shared.types'; +import { getTestUtils } from '../../testing/getTestUtils'; +import { getLgIds } from '../../utils'; + +import { Title } from './Title'; +import { TitleProps } from './Title.types'; + +// Mock H3 to properly forward refs for testing while preserving polymorphic behavior +jest.mock('@leafygreen-ui/typography', () => { + const React = require('react'); + return { + ...jest.requireActual('@leafygreen-ui/typography'), + // eslint-disable-next-line react/display-name + H3: React.forwardRef( + ( + { as, ...props }: { as?: React.ElementType } & Record, + ref: React.Ref, + ) => { + const Component = as || 'h3'; + return React.createElement(Component, { ...props, ref }); + }, + ), + }; +}); + +const renderTitle = (props: TitleProps) => { + const lgIds = getLgIds(); + return render( + + + </CollectionToolbarProvider>, + ); +}; + +describe('packages/collection-toolbar/CollectionToolbar/components/Title', () => { + test('renders children correctly', () => { + renderTitle({ children: 'Test Title' }); + const utils = getTestUtils(); + expect(screen.getByText('Test Title')).toBeInTheDocument(); + expect(utils.getTitle()).toBeInTheDocument(); + }); + + test('renders as an h3 element as default', () => { + renderTitle({ children: 'Test Title' }); + expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument(); + }); + + test('renders as a p element when using as prop', () => { + renderTitle({ children: 'Test Title', as: 'p' }); + expect(screen.getByText('Test Title').tagName).toBe('P'); + }); + + test('applies className to the rendered element', () => { + renderTitle({ children: 'Test Title', className: 'custom-class' }); + expect(screen.getByText('Test Title')).toHaveClass('custom-class'); + }); + + test('does not allow darkMode prop', () => { + // @ts-expect-error: darkMode prop is not allowed + renderTitle({ children: 'Test Title', darkMode: true }); + const utils = getTestUtils(); + expect(utils.getTitle()).not.toHaveClass('dark-mode'); + }); + + test('has the correct static property for compound component identification', () => { + expect(Title[CollectionToolbarSubComponentProperty.Title]).toBe(true); + }); + + test('applies data-lgid attribute from context', () => { + renderTitle({ children: 'Test Title' }); + const titleElement = screen.getByText('Test Title'); + expect(titleElement).toHaveAttribute( + 'data-lgid', + 'lg-collection_toolbar-title', + ); + }); + + test('spreads additional HTML attributes to the element', () => { + renderTitle({ + children: 'Test Title', + 'data-testid': 'custom-title', + 'aria-label': 'Custom label', + }); + const titleElement = screen.getByText('Test Title'); + expect(titleElement).toHaveAttribute('data-testid', 'custom-title'); + expect(titleElement).toHaveAttribute('aria-label', 'Custom label'); + }); + + test('forwards ref to the rendered element', () => { + const ref = createRef<HTMLHeadingElement>(); + const lgIds = getLgIds(); + render( + <CollectionToolbarProvider lgIds={lgIds}> + <Title ref={ref}>Test Title + , + ); + expect(ref.current).not.toBeNull(); + expect(ref.current).toBe(screen.getByText('Test Title')); + }); +}); diff --git a/packages/collection-toolbar/src/components/Title/Title.tsx b/packages/collection-toolbar/src/components/Title/Title.tsx new file mode 100644 index 0000000000..d15e0ff92e --- /dev/null +++ b/packages/collection-toolbar/src/components/Title/Title.tsx @@ -0,0 +1,36 @@ +import React, { forwardRef } from 'react'; + +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; +import { H3 } from '@leafygreen-ui/typography'; + +import { useCollectionToolbarContext } from '../../Context/CollectionToolbarProvider'; +import { CollectionToolbarSubComponentProperty } from '../../shared.types'; + +import { TitleProps } from './Title.types'; + +/** + * Title is a compound component that renders a title for CollectionToolbar. + * It will only render if the CollectionToolbar variant is set to Collapsible. + */ +export const Title = CompoundSubComponent( + // eslint-disable-next-line react/display-name + forwardRef( + ({ className, children, ...rest }, fwdRef) => { + const { lgIds } = useCollectionToolbarContext(); + return ( +

+ {children} +

+ ); + }, + ), + { + displayName: 'Title', + key: CollectionToolbarSubComponentProperty.Title, + }, +); diff --git a/packages/collection-toolbar/src/components/Title/Title.types.ts b/packages/collection-toolbar/src/components/Title/Title.types.ts new file mode 100644 index 0000000000..51c9c4f5a1 --- /dev/null +++ b/packages/collection-toolbar/src/components/Title/Title.types.ts @@ -0,0 +1,4 @@ +import { DarkModeProps } from '@leafygreen-ui/lib'; +import { H3Props } from '@leafygreen-ui/typography'; + +export interface TitleProps extends Omit {} diff --git a/packages/collection-toolbar/src/components/Title/index.ts b/packages/collection-toolbar/src/components/Title/index.ts new file mode 100644 index 0000000000..65f4825450 --- /dev/null +++ b/packages/collection-toolbar/src/components/Title/index.ts @@ -0,0 +1,2 @@ +export { Title } from './Title'; +export type { TitleProps } from './Title.types'; diff --git a/packages/collection-toolbar/src/components/index.ts b/packages/collection-toolbar/src/components/index.ts new file mode 100644 index 0000000000..65fd5e9785 --- /dev/null +++ b/packages/collection-toolbar/src/components/index.ts @@ -0,0 +1,14 @@ +export { + Actions, + type ActionsProps, + Button, + type ButtonProps, + ButtonVariant, + Menu, + MenuItem, + type MenuItemProps, + type MenuProps, + Pagination, + type PaginationProps, +} from './Actions'; +export { Title, type TitleProps } from './Title'; diff --git a/packages/collection-toolbar/src/index.ts b/packages/collection-toolbar/src/index.ts new file mode 100644 index 0000000000..44e78702b5 --- /dev/null +++ b/packages/collection-toolbar/src/index.ts @@ -0,0 +1,14 @@ +export { + CollectionToolbar, + type CollectionToolbarProps, +} from './CollectionToolbar'; +export { + type ActionsProps, + type ButtonProps, + ButtonVariant, + type MenuItemProps, + type MenuProps, + type PaginationProps, + type TitleProps, +} from './components'; +export { Size, Variant } from './shared.types'; diff --git a/packages/collection-toolbar/src/shared.types.ts b/packages/collection-toolbar/src/shared.types.ts new file mode 100644 index 0000000000..bc235dae7f --- /dev/null +++ b/packages/collection-toolbar/src/shared.types.ts @@ -0,0 +1,54 @@ +import { Size as ImportedSize } from '@leafygreen-ui/tokens'; + +/** + * Variant options for CollectionToolbar. + * + * @default 'default' + */ +export const Variant = { + Compact: 'compact', + Default: 'default', + Collapsible: 'collapsible', +} as const; +export type Variant = (typeof Variant)[keyof typeof Variant]; + +/** + * Size options for CollectionToolbar. + * + * @default 'default' + */ +export const Size = { + Default: ImportedSize.Default, + Small: ImportedSize.Small, +} as const; +export type Size = (typeof Size)[keyof typeof Size]; + +/** + * Static property names used to identify CollectionToolbar compound components. + * These are implementation details for the compound component pattern and should not be exported. + */ +export const CollectionToolbarSubComponentProperty = { + Title: 'isCollectionToolbarTitle', + SearchInput: 'isCollectionToolbarSearchInput', + Actions: 'isCollectionToolbarActions', + Filters: 'isCollectionToolbarFilters', +} as const; + +/** + * Type representing the possible static property names for CollectionToolbar sub components. + */ +export type CollectionToolbarSubComponentProperty = + (typeof CollectionToolbarSubComponentProperty)[keyof typeof CollectionToolbarSubComponentProperty]; + +/** + * Static property names used to identify CollectionToolbarActions compound components. + * These are implementation details for the compound component pattern and should not be exported. + */ +export const CollectionToolbarActionsSubComponentProperty = { + Button: 'isCollectionToolbarActionsButton', + Pagination: 'isCollectionToolbarActionsPagination', + Menu: 'isCollectionToolbarActionsMenu', + MenuItem: 'isCollectionToolbarActionsMenuItem', +} as const; +export type CollectionToolbarActionsSubComponentProperty = + (typeof CollectionToolbarActionsSubComponentProperty)[keyof typeof CollectionToolbarActionsSubComponentProperty]; diff --git a/packages/collection-toolbar/src/testing/getTestUtils.spec.tsx b/packages/collection-toolbar/src/testing/getTestUtils.spec.tsx new file mode 100644 index 0000000000..3854df4c20 --- /dev/null +++ b/packages/collection-toolbar/src/testing/getTestUtils.spec.tsx @@ -0,0 +1,3 @@ +describe('packages/collection-toolbar/getTestUtils', () => { + test('condition', () => {}); +}); diff --git a/packages/collection-toolbar/src/testing/getTestUtils.tsx b/packages/collection-toolbar/src/testing/getTestUtils.tsx new file mode 100644 index 0000000000..4c93725662 --- /dev/null +++ b/packages/collection-toolbar/src/testing/getTestUtils.tsx @@ -0,0 +1,38 @@ +import { findByLgId, getByLgId, queryByLgId } from '@lg-tools/test-harnesses'; + +import { LgIdString } from '@leafygreen-ui/lib'; + +import { DEFAULT_LGID_ROOT, getLgIds } from '../utils'; + +import { TestUtilsReturnType } from './getTestUtils.types'; + +export const getTestUtils = ( + lgId: LgIdString = DEFAULT_LGID_ROOT, +): TestUtilsReturnType => { + const lgIds = getLgIds(lgId); + + const findCollectionToolbar = () => findByLgId!(lgIds.root); + const getCollectionToolbar = () => getByLgId!(lgIds.root); + const queryCollectionToolbar = () => queryByLgId!(lgIds.root); + + const getTitle = () => getByLgId!(lgIds.title); + const findTitle = () => findByLgId!(lgIds.title); + const queryTitle = () => queryByLgId!(lgIds.title); + + const getPagination = () => getByLgId!(`${lgIds.pagination}-navigation`); + const findPagination = () => findByLgId!(`${lgIds.pagination}-navigation`); + const queryPagination = () => + queryByLgId!(`${lgIds.pagination}-navigation`); + + return { + findCollectionToolbar, + getCollectionToolbar, + queryCollectionToolbar, + getTitle, + findTitle, + queryTitle, + getPagination, + findPagination, + queryPagination, + }; +}; diff --git a/packages/collection-toolbar/src/testing/getTestUtils.types.ts b/packages/collection-toolbar/src/testing/getTestUtils.types.ts new file mode 100644 index 0000000000..6afa602166 --- /dev/null +++ b/packages/collection-toolbar/src/testing/getTestUtils.types.ts @@ -0,0 +1,13 @@ +export interface TestUtilsReturnType { + findCollectionToolbar: () => Promise; + getCollectionToolbar: () => T; + queryCollectionToolbar: () => T | null; + + getTitle: () => T; + findTitle: () => Promise; + queryTitle: () => T | null; + + getPagination: () => T; + findPagination: () => Promise; + queryPagination: () => T | null; +} diff --git a/packages/collection-toolbar/src/testing/index.ts b/packages/collection-toolbar/src/testing/index.ts new file mode 100644 index 0000000000..4c102995fa --- /dev/null +++ b/packages/collection-toolbar/src/testing/index.ts @@ -0,0 +1,2 @@ +export { getTestUtils } from './getTestUtils'; +export { type TestUtilsReturnType } from './getTestUtils.types'; diff --git a/packages/collection-toolbar/src/utils.ts b/packages/collection-toolbar/src/utils.ts new file mode 100644 index 0000000000..321d2901ac --- /dev/null +++ b/packages/collection-toolbar/src/utils.ts @@ -0,0 +1,18 @@ +import { LgIdString } from '@leafygreen-ui/lib'; + +export const DEFAULT_LGID_ROOT = 'lg-collection_toolbar'; + +export const getLgIds = (root: LgIdString = DEFAULT_LGID_ROOT) => { + const ids = { + root, + title: `${root}-title`, + actions: `${root}-actions`, + button: `${root}-button`, + pagination: `${root}-pagination`, + menu: `${root}-menu`, + menuItem: `${root}-menu-item`, + } as const; + return ids; +}; + +export type GetLgIdsReturnType = ReturnType; diff --git a/packages/collection-toolbar/tsconfig.json b/packages/collection-toolbar/tsconfig.json new file mode 100644 index 0000000000..5f7a517a65 --- /dev/null +++ b/packages/collection-toolbar/tsconfig.json @@ -0,0 +1,55 @@ +{ + "extends": "@lg-tools/build/config/package.tsconfig.json", + "compilerOptions": { + "paths": { + "@leafygreen-ui/icon/dist/*": ["../icon/src/generated/*"], + "@leafygreen-ui/*": ["../*/src"] + } + }, + "include": ["src/**/*"], + "exclude": ["**/*.spec.*", "**/*.stories.*"], + "references": [ + { + "path": "../button" + }, + { + "path": "../compound-component" + }, + { + "path": "../emotion" + }, + { + "path": "../icon" + }, + { + "path": "../icon-button" + }, + { + "path": "../hooks" + }, + { + "path": "../leafygreen-provider" + }, + { + "path": "../lib" + }, + { + "path": "../menu" + }, + { + "path": "../tokens" + }, + { + "path": "../pagination" + }, + { + "path": "../tooltip" + }, + { + "path": "../typography" + }, + { + "path": "../../tools/test-harnesses" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd14a209e1..825bcbe251 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1426,6 +1426,51 @@ importers: specifier: workspace:^ version: link:../../tools/build + packages/collection-toolbar: + dependencies: + '@leafygreen-ui/button': + specifier: workspace:^ + version: link:../button + '@leafygreen-ui/compound-component': + specifier: workspace:^ + version: link:../compound-component + '@leafygreen-ui/emotion': + specifier: workspace:^ + version: link:../emotion + '@leafygreen-ui/hooks': + specifier: workspace:^ + version: link:../hooks + '@leafygreen-ui/icon': + specifier: workspace:^ + version: link:../icon + '@leafygreen-ui/icon-button': + specifier: workspace:^ + version: link:../icon-button + '@leafygreen-ui/leafygreen-provider': + specifier: workspace:^5.0.0 || ^4.0.0 || ^3.2.0 + version: link:../leafygreen-provider + '@leafygreen-ui/lib': + specifier: workspace:^ + version: link:../lib + '@leafygreen-ui/menu': + specifier: workspace:^ + version: link:../menu + '@leafygreen-ui/pagination': + specifier: workspace:^ + version: link:../pagination + '@leafygreen-ui/tokens': + specifier: workspace:^ + version: link:../tokens + '@leafygreen-ui/tooltip': + specifier: workspace:^ + version: link:../tooltip + '@leafygreen-ui/typography': + specifier: workspace:^ + version: link:../typography + '@lg-tools/test-harnesses': + specifier: workspace:^ + version: link:../../tools/test-harnesses + packages/combobox: dependencies: '@leafygreen-ui/checkbox': diff --git a/tools/install/src/ALL_PACKAGES.ts b/tools/install/src/ALL_PACKAGES.ts index e454f671f1..c2c7933a89 100644 --- a/tools/install/src/ALL_PACKAGES.ts +++ b/tools/install/src/ALL_PACKAGES.ts @@ -14,6 +14,7 @@ export const ALL_PACKAGES = [ '@leafygreen-ui/chip', '@leafygreen-ui/code', '@leafygreen-ui/code-editor', + '@leafygreen-ui/collection-toolbar', '@leafygreen-ui/combobox', '@leafygreen-ui/compound-component', '@leafygreen-ui/confirmation-modal',