diff --git a/.changeset/collection-toolbar.md b/.changeset/collection-toolbar.md new file mode 100644 index 0000000000..0ccb9d2a3b --- /dev/null +++ b/.changeset/collection-toolbar.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/collection-toolbar': major +--- + +Initial release of `CollectionToolbar`: a compound component for collection/list toolbars with optional Title, SearchInput, Filters (Select, Combobox, DatePicker, NumberInput, SegmentedControl, TextInput), and Actions (buttons, menu, pagination). Supports default, compact, and collapsible variants and default/small sizes. diff --git a/.changeset/major-lines-spend.md b/.changeset/major-lines-spend.md new file mode 100644 index 0000000000..18dd21f17c --- /dev/null +++ b/.changeset/major-lines-spend.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/combobox': minor +--- + +Exported out InternalComboboxOptionProps from ComboBoxOption 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..fb12a5a10c --- /dev/null +++ b/packages/collection-toolbar/package.json @@ -0,0 +1,64 @@ + +{ + "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/a11y": "workspace:^", + "@leafygreen-ui/button": "workspace:^", + "@leafygreen-ui/combobox": "workspace:^", + "@leafygreen-ui/compound-component": "workspace:^", + "@leafygreen-ui/date-picker": "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/number-input": "workspace:^", + "@leafygreen-ui/pagination": "workspace:^", + "@leafygreen-ui/search-input": "workspace:^", + "@leafygreen-ui/segmented-control": "workspace:^", + "@leafygreen-ui/select": "workspace:^", + "@leafygreen-ui/text-input": "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..0232605c59 --- /dev/null +++ b/packages/collection-toolbar/src/CollectionToolbar.stories.tsx @@ -0,0 +1,425 @@ +import React from 'react'; +import { storybookArgTypes, StoryMetaType } from '@lg-tools/storybook-utils'; +import { StoryFn, StoryObj } from '@storybook/react'; + +import { css } from '@leafygreen-ui/emotion'; +import { Icon } from '@leafygreen-ui/icon'; + +import { ButtonVariant, CollectionToolbar, Size, Variant } from '.'; + +const meta: StoryMetaType = { + title: 'Components/CollectionToolbar', + component: CollectionToolbar, + decorators: [ + StoryFn => ( +
+ +
+ ), + ], + parameters: { + default: 'LiveExample', + generate: { + storyNames: ['Default', 'Compact', 'Collapsible'], + combineArgs: { + darkMode: [false, true], + size: Object.values(Size), + }, + }, + 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), + }, + children: { + control: 'none', + }, + }, +}; + +export default meta; + +const renderDefaultChildren = () => ( + <> + Collection Title + + + + + Option 1 + + } + > + Option 2 + + + + + + + + + Select Option 1 + + + Select Option 2 + + + + + + Action + + + Action + + {}} + onForwardArrowClick={() => {}} + itemsPerPage={10} + numTotalItems={100} + /> + + + Menu Item + + + Menu Item + + + Menu Item + + + + + +); + +const renderCompactChildren = () => ( + <> + Collection Title + + + + + + + Select Option 1 + + + Select Option 2 + + + + + + Action + + + Action + + {}} + onForwardArrowClick={() => {}} + itemsPerPage={10} + numTotalItems={100} + /> + + + Menu Item + + + Menu Item + + + Menu Item + + + + + +); + +const Template: StoryFn = props => ( + + {props.children ?? renderDefaultChildren()} + +); + +export const LiveExample: StoryObj = { + render: Template, + parameters: { + chromatic: { + disableSnapshot: false, + }, + }, +}; + +export const Default: StoryObj = { + render: Template, + args: { + variant: Variant.Default, + children: renderDefaultChildren(), + }, + parameters: { + chromatic: { + disableSnapshot: false, + }, + }, +}; + +export const Compact: StoryObj = { + render: Template, + args: { + variant: Variant.Compact, + children: renderCompactChildren(), + }, + parameters: { + chromatic: { + disableSnapshot: false, + }, + }, +}; + +export const Collapsible: StoryObj = { + render: Template, + args: { + variant: Variant.Collapsible, + children: renderDefaultChildren(), + }, + parameters: { + title: 'Variant: Collapsible', + chromatic: { + disableSnapshot: false, + }, + }, +}; + +export const SearchInput: StoryFn = ({ + placeholder, + 'aria-label': ariaLabel = '', + 'aria-labelledby': ariaLabelledby = '', + ...props +}) => ( + + + +); + +SearchInput.argTypes = { + placeholder: { + control: 'text', + defaultValue: 'Search for a collection', + }, + 'aria-labelledby': { + control: 'text', + defaultValue: 'search-input-label', + }, +}; + +export const Title: StoryFn = props => ( + + Collection Title + +); + +Title.args = { + variant: Variant.Collapsible, +}; + +export const Actions: StoryObj = { + render: ({ showToggleButton, ...props }) => ( + + Collection Title + + + Action + + + Action + + {}} + onForwardArrowClick={() => {}} + itemsPerPage={10} + numTotalItems={100} + /> + + + Menu Item + + + Menu Item + + + + + ), + argTypes: { + showToggleButton: { + description: + 'Shows the toggle button. Only shows if the variant is collapsible.', + control: 'boolean', + defaultValue: false, + }, + }, + parameters: { + generate: { + combineArgs: { + darkMode: [false, true], + size: Object.values(Size), + variant: Object.values(Variant), + showToggleButton: [true, false], + }, + }, + }, +}; + +export const Filters: StoryObj = { + render: props => ( + + + + + Segmented Control Option 1 + + } + > + Segmented Control Option 2 + + + + + + + {}} + index={0} + displayName="Combobox Option 1" + > + Combobox Option 1 + + {}} + index={0} + displayName="Combobox Option 2" + > + Combobox Option 2 + + + + + + Select Option 1 + + + Select Option 2 + + + + + ), + parameters: { + generate: { + combineArgs: { + darkMode: [false, true], + size: Object.values(Size), + variant: Object.values(Variant), + }, + }, + }, +}; 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..1f92219d99 --- /dev/null +++ b/packages/collection-toolbar/src/CollectionToolbar/CollectionToolbar.spec.tsx @@ -0,0 +1,154 @@ +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: default', () => { + test('renders actions before filters', () => { + render( + + + + + + + Action + + + , + ); + + const utils = getTestUtils(); + const actions = utils.getActions(); + const filters = utils.getFilters(); + + expect(actions).toBeInTheDocument(); + expect(filters).toBeInTheDocument(); + + // Actions should come before filters in DOM order + // Using compareDocumentPosition: DOCUMENT_POSITION_FOLLOWING (4) means the node follows + expect(actions.compareDocumentPosition(filters)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + }); + }); + + describe('variant: compact', () => { + test('renders filters before actions', () => { + render( + + + + + + + Action + + + , + ); + + const utils = getTestUtils(); + const actions = utils.getActions(); + const filters = utils.getFilters(); + + expect(actions).toBeInTheDocument(); + expect(filters).toBeInTheDocument(); + + expect(filters.compareDocumentPosition(actions)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + }); + }); + + 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(); + }); + + test('renders filters inside collapsible content region', () => { + render( + + Test Title + + + + + + Action + + + , + ); + + const utils = getTestUtils(); + const actions = utils.getActions(); + const filters = utils.getFilters(); + + // Both should be rendered + expect(actions).toBeInTheDocument(); + expect(filters).toBeInTheDocument(); + + // Filters should be inside the collapsible content region + const collapsibleRegion = screen.getByRole('region'); + expect(collapsibleRegion).toBeInTheDocument(); + expect(collapsibleRegion).toContainElement(filters); + + // Actions should NOT have filters as a direct following sibling + expect(actions?.nextElementSibling).not.toBe(filters); + }); + + test('collapsible region is labelled by title', () => { + render( + + Test Title + + + + , + ); + + const utils = getTestUtils(); + const title = utils.getTitle(); + const collapsibleRegion = screen.getByRole('region'); + + // Title should have an id + expect(title).toHaveAttribute('id'); + const titleId = title.getAttribute('id'); + + // Collapsible region should have an id and be labelled by the title + expect(collapsibleRegion).toHaveAttribute('id'); + expect(collapsibleRegion).toHaveAttribute('aria-labelledby', titleId); + }); + }); +}); 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..a0f0892b37 --- /dev/null +++ b/packages/collection-toolbar/src/CollectionToolbar/CollectionToolbar.styles.ts @@ -0,0 +1,23 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { spacing } from '@leafygreen-ui/tokens'; + +export const baseStyles = css` + display: flex; + flex-flow: row wrap; + align-items: flex-start; + justify-content: space-between; + gap: ${spacing[200]}px; + padding: ${spacing[200]}px ${spacing[600]}px; +`; + +export const collapsibleContentStyles = css` + flex: 100%; + display: grid; + gap: ${spacing[200]}px; +`; + +export const getCollectionToolbarStyles = ({ + className, +}: { + 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..f0336536dd --- /dev/null +++ b/packages/collection-toolbar/src/CollectionToolbar/CollectionToolbar.tsx @@ -0,0 +1,123 @@ +import React, { forwardRef, useMemo } from 'react'; + +import { + CompoundComponent, + findChild, +} from '@leafygreen-ui/compound-component'; +import { useIdAllocator } from '@leafygreen-ui/hooks'; + +import { Actions, Filters, SearchInput, Title } from '../components'; +import { CollapsibleContent } from '../components/CollapsibleContent'; +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 titleId = useIdAllocator({ prefix: 'collection-toolbar-title' }); + const collapsibleContentId = useIdAllocator({ + prefix: 'collection-toolbar-collapsible-content', + }); + + const title = findChild( + children, + CollectionToolbarSubComponentProperty.Title, + ); + const searchInput = findChild( + children, + CollectionToolbarSubComponentProperty.SearchInput, + ); + + const actions = findChild( + children, + CollectionToolbarSubComponentProperty.Actions, + ); + + const filters = findChild( + children, + CollectionToolbarSubComponentProperty.Filters, + ); + + const isCollapsible = variant === Variant.Collapsible; + const isCompact = variant === Variant.Compact; + + const content = useMemo(() => { + if (isCompact) { + return ( + <> + {searchInput} + {filters} + {actions} + + ); + } + + if (isCollapsible) { + return ( + <> + {title} + {actions} + + + ); + } + + return ( + <> + {searchInput} + {actions} + {filters} + + ); + }, [isCompact, isCollapsible, title, searchInput, filters, actions]); + + return ( + +
+ {content} +
+
+ ); + }, + ), + { + displayName: 'CollectionToolbar', + Title, + Actions, + Filters, + SearchInput, + }, +); 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..f439c56edb --- /dev/null +++ b/packages/collection-toolbar/src/Context/CollectionToolbarProvider.tsx @@ -0,0 +1,123 @@ +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; + /** + * The ID of the title element. + */ + titleId?: string; + /** + * The ID of the collapsible content element. + */ + collapsibleContentId?: string; +} + +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, + titleId, + collapsibleContentId, +}: 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, + titleId, + collapsibleContentId, + }; + }, [ + size, + variant, + lgIds, + isCollapsed, + handleToggleCollapsed, + darkMode, + titleId, + collapsibleContentId, + ]); + + 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..429e78761a --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Action.styles.ts @@ -0,0 +1,55 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { + breakpoints, + spacing, + transitionDuration, +} from '@leafygreen-ui/tokens'; + +import { Variant } from '../../shared.types'; +import { CUSTOM_BREAKPOINT } from '../constants'; + +export const baseStyles = css` + display: flex; + flex-direction: row; + align-items: center; + gap: ${spacing[100]}px; + margin-left: ${spacing[1800]}px; + + @media only screen and (max-width: ${CUSTOM_BREAKPOINT}px) { + margin-left: 0; + } +`; + +const iconBaseStyles = css` + transition: transform ${transitionDuration.default}ms ease-in-out; +`; + +const iconExpandedStyles = css` + transform: rotate(180deg); +`; + +export const getIconStyles = ({ isExpanded }: { isExpanded: boolean }) => + cx(iconBaseStyles, { + [iconExpandedStyles]: isExpanded, + }); + +const compactStyles = css` + @media only screen and (max-width: ${breakpoints.Desktop}px) { + margin-left: 0; + } +`; + +export const getActionStyles = ({ + className, + variant, +}: { + className?: string; + variant?: Variant; +}) => + cx( + baseStyles, + { + [compactStyles]: variant === Variant.Compact, + }, + 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..2904e3dc6a --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Actions.spec.tsx @@ -0,0 +1,350 @@ +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 { getTestUtils } from '../../testing/getTestUtils'; +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 { getActions } = getTestUtils(); + +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 = getActions(); + 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('icon has rotation transform when expanded (isCollapsed is false)', () => { + renderActions({ + variant: Variant.Collapsible, + showToggleButton: true, + isCollapsed: false, + }); + const toggleButton = screen.getByLabelText('Toggle collapse'); + const icon = toggleButton.querySelector('svg'); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveStyle({ transform: 'rotate(180deg)' }); + }); + + test('icon does not have rotation transform when collapsed (isCollapsed is true)', () => { + renderActions({ + variant: Variant.Collapsible, + showToggleButton: true, + isCollapsed: true, + }); + const toggleButton = screen.getByLabelText('Toggle collapse'); + const icon = toggleButton.querySelector('svg'); + expect(icon).toBeInTheDocument(); + // When collapsed, the icon should not have the rotation transform + expect(icon).not.toHaveStyle({ transform: 'rotate(180deg)' }); + }); + + 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 = getActions(); + 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 = getActions(); + 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..7a04db06fb --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Actions.tsx @@ -0,0 +1,115 @@ +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, getIconStyles } from './Action.styles'; +import { ActionsProps } from './Actions.types'; +import { Button } from './Button'; +import { Menu } from './Menu'; +import { MenuItem } from './MenuItem'; +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, + MenuItem, + }, +); 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..6f8217a9ad --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Menu/Menu.spec.tsx @@ -0,0 +1,80 @@ +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 { getLgIds } from '../../../utils'; +import { MenuItem } from '../MenuItem'; + +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(); + }); + }); + }); +}); 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..6562a72517 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Menu/Menu.tsx @@ -0,0 +1,46 @@ +import React, { forwardRef } from 'react'; + +import { + CompoundSubComponent, + findChildren, +} 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'; + +export const Menu = CompoundSubComponent( + // eslint-disable-next-line react/display-name + forwardRef(({ children, ...props }, fwdRef) => { + const { lgIds } = useCollectionToolbarContext(); + + const menuItems = findChildren( + children, + CollectionToolbarActionsSubComponentProperty.MenuItem, + ); + + return ( + + + + } + > + {menuItems} + + ); + }), + { + displayName: 'Menu', + key: CollectionToolbarActionsSubComponentProperty.Menu, + }, +); 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..c9a33ce919 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Menu/Menu.types.ts @@ -0,0 +1,7 @@ +import { + MenuProps as LGMenuProps, + MenuVariant as LGMenuVariant, +} from '@leafygreen-ui/menu'; + +export type MenuProps = Omit; +export const MenuVariant = LGMenuVariant; 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..e3e0324841 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Menu/index.ts @@ -0,0 +1,2 @@ +export { Menu } from './Menu'; +export { type MenuProps, MenuVariant } from './Menu.types'; diff --git a/packages/collection-toolbar/src/components/Actions/MenuItem/MenuItem.spec.tsx b/packages/collection-toolbar/src/components/Actions/MenuItem/MenuItem.spec.tsx new file mode 100644 index 0000000000..a971ff112e --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/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/MenuItem/MenuItem.tsx b/packages/collection-toolbar/src/components/Actions/MenuItem/MenuItem.tsx new file mode 100644 index 0000000000..c5560587d3 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/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/MenuItem/MenuItem.types.ts b/packages/collection-toolbar/src/components/Actions/MenuItem/MenuItem.types.ts new file mode 100644 index 0000000000..2bf77816e4 --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/MenuItem/MenuItem.types.ts @@ -0,0 +1,7 @@ +import { + menuItemClassName, + MenuItemProps as LGMenuItemProps, +} from '@leafygreen-ui/menu'; + +export type MenuItemProps = LGMenuItemProps; +export { menuItemClassName }; diff --git a/packages/collection-toolbar/src/components/Actions/MenuItem/index.ts b/packages/collection-toolbar/src/components/Actions/MenuItem/index.ts new file mode 100644 index 0000000000..6bc9b9e34c --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/MenuItem/index.ts @@ -0,0 +1,2 @@ +export { MenuItem } from './MenuItem'; +export { menuItemClassName, type MenuItemProps } from './MenuItem.types'; 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..bb83025a2e --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/Pagination/Pagination.styles.ts @@ -0,0 +1,10 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { spacing } from '@leafygreen-ui/tokens'; + +const baseStyles = css` + width: max-content; + 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..0340032d8e --- /dev/null +++ b/packages/collection-toolbar/src/components/Actions/index.ts @@ -0,0 +1,6 @@ +export { Actions } from './Actions'; +export type { ActionsProps } from './Actions.types'; +export { Button, type ButtonProps, ButtonVariant } from './Button'; +export { Menu, type MenuProps, MenuVariant } from './Menu'; +export { MenuItem, menuItemClassName, type MenuItemProps } from './MenuItem'; +export { Pagination, type PaginationProps } from './Pagination'; diff --git a/packages/collection-toolbar/src/components/CollapsibleContent/CollapsibleContent.styles.ts b/packages/collection-toolbar/src/components/CollapsibleContent/CollapsibleContent.styles.ts new file mode 100644 index 0000000000..963e840ca3 --- /dev/null +++ b/packages/collection-toolbar/src/components/CollapsibleContent/CollapsibleContent.styles.ts @@ -0,0 +1,36 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { spacing, transitionDuration } from '@leafygreen-ui/tokens'; + +export const collapsibleContentBaseStyles = css` + display: grid; + width: -webkit-fill-available; + transition-property: height, grid-template-rows, opacity, visibility; + transition-duration: ${transitionDuration.slower}ms; + transition-timing-function: ease-in-out; + + grid-template-rows: 1fr; + opacity: 1; + visibility: visible; +`; + +const collapsedStyles = css` + grid-template-rows: 0fr; + opacity: 0; + visibility: hidden; +`; + +export const getCollapsibleContentStyles = ({ + isCollapsed, +}: { + isCollapsed?: boolean; +}) => + cx(collapsibleContentBaseStyles, { + [collapsedStyles]: isCollapsed, + }); + +export const innerContentWrapperStyles = css` + display: grid; + gap: ${spacing[200]}px; + overflow: hidden; + min-height: 0; +`; diff --git a/packages/collection-toolbar/src/components/CollapsibleContent/CollapsibleContent.tsx b/packages/collection-toolbar/src/components/CollapsibleContent/CollapsibleContent.tsx new file mode 100644 index 0000000000..1e43a95afd --- /dev/null +++ b/packages/collection-toolbar/src/components/CollapsibleContent/CollapsibleContent.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { useCollectionToolbarContext } from '../../Context/CollectionToolbarProvider'; + +import { + getCollapsibleContentStyles, + innerContentWrapperStyles, +} from './CollapsibleContent.styles'; +import { CollapsibleContentProps } from './CollapsibleContent.types'; + +/** + * @internal + * Internal CollapsibleContent component + * + * @remarks + * This component is intended to be used within the context of the `CollectionToolbarProvider` + * created in `CollectionToolbar.tsx`. It relies on the CollectionToolbarContext to access + * the `isCollapsed` state for styling purposes. + * + * Do not use `CollapsibleContent` directly—always use the `CollectionToolbar` component, + * which ensures the correct context is provided. + */ +export const CollapsibleContent = ({ + searchInput, + filters, +}: CollapsibleContentProps) => { + const { isCollapsed, collapsibleContentId, titleId } = + useCollectionToolbarContext(); + + return ( +
+
+ {searchInput} + {filters} +
+
+ ); +}; + +CollapsibleContent.displayName = 'CollapsibleContent'; diff --git a/packages/collection-toolbar/src/components/CollapsibleContent/CollapsibleContent.types.ts b/packages/collection-toolbar/src/components/CollapsibleContent/CollapsibleContent.types.ts new file mode 100644 index 0000000000..1af553392b --- /dev/null +++ b/packages/collection-toolbar/src/components/CollapsibleContent/CollapsibleContent.types.ts @@ -0,0 +1,5 @@ +export interface CollapsibleContentProps { + searchInput: React.ReactNode; + filters: React.ReactNode; + headingId?: string; +} diff --git a/packages/collection-toolbar/src/components/CollapsibleContent/index.ts b/packages/collection-toolbar/src/components/CollapsibleContent/index.ts new file mode 100644 index 0000000000..8ec524b354 --- /dev/null +++ b/packages/collection-toolbar/src/components/CollapsibleContent/index.ts @@ -0,0 +1,2 @@ +export { CollapsibleContent } from './CollapsibleContent'; +export { type CollapsibleContentProps } from './CollapsibleContent.types'; diff --git a/packages/collection-toolbar/src/components/Filters/Combobox/Combobox.styles.ts b/packages/collection-toolbar/src/components/Filters/Combobox/Combobox.styles.ts new file mode 100644 index 0000000000..f39c57b983 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/Combobox/Combobox.styles.ts @@ -0,0 +1,8 @@ +import { css, cx } from '@leafygreen-ui/emotion'; + +const baseComboboxStyles = css` + width: fit-content; +`; + +export const getComboboxStyles = ({ className }: { className?: string }) => + cx(baseComboboxStyles, className); diff --git a/packages/collection-toolbar/src/components/Filters/Combobox/Combobox.tsx b/packages/collection-toolbar/src/components/Filters/Combobox/Combobox.tsx new file mode 100644 index 0000000000..b945538b11 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/Combobox/Combobox.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import { Combobox as LGCombobox } from '@leafygreen-ui/combobox'; +import { + CompoundSubComponent, + findChildren, +} from '@leafygreen-ui/compound-component'; + +import { useCollectionToolbarContext } from '../../../Context/CollectionToolbarProvider'; +import { CollectionToolbarFiltersSubComponentProperty } from '../share.types'; + +import { getComboboxStyles } from './Combobox.styles'; +import { ComboboxProps } from './Combobox.types'; + +// Note: LGCombobox doesn't support ref forwarding +const ComboboxComponent = ({ + className, + children, + ...props +}: ComboboxProps) => { + const { size } = useCollectionToolbarContext(); + + const comboboxOptions = findChildren( + children, + CollectionToolbarFiltersSubComponentProperty.ComboboxOption, + ); + + return ( + + {comboboxOptions} + + ); +}; + +ComboboxComponent.displayName = 'Combobox'; + +export const Combobox = CompoundSubComponent(ComboboxComponent, { + displayName: 'Combobox', + key: CollectionToolbarFiltersSubComponentProperty.Combobox, +}); diff --git a/packages/collection-toolbar/src/components/Filters/Combobox/Combobox.types.ts b/packages/collection-toolbar/src/components/Filters/Combobox/Combobox.types.ts new file mode 100644 index 0000000000..f9ce38f93e --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/Combobox/Combobox.types.ts @@ -0,0 +1,8 @@ +import { ComboboxProps as LGComboboxProps } from '@leafygreen-ui/combobox'; + +import { DistributiveOmit } from '../../../shared.types'; + +export type ComboboxProps = DistributiveOmit< + LGComboboxProps, + 'size' | 'darkMode' +>; diff --git a/packages/collection-toolbar/src/components/Filters/Combobox/index.ts b/packages/collection-toolbar/src/components/Filters/Combobox/index.ts new file mode 100644 index 0000000000..493ffa22e5 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/Combobox/index.ts @@ -0,0 +1,2 @@ +export { Combobox } from './Combobox'; +export type { ComboboxProps } from './Combobox.types'; diff --git a/packages/collection-toolbar/src/components/Filters/ComboboxOption/ComboboxOption.tsx b/packages/collection-toolbar/src/components/Filters/ComboboxOption/ComboboxOption.tsx new file mode 100644 index 0000000000..c00d00ba84 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/ComboboxOption/ComboboxOption.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { ComboboxOption as LGComboboxOption } from '@leafygreen-ui/combobox'; +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; + +import { CollectionToolbarFiltersSubComponentProperty } from '../share.types'; + +import { ComboboxOptionProps } from './ComboboxOption.types'; + +// Note: LGCombobox doesn't support ref forwarding +// LGComboboxOption technically does but doesn't play nice with ref forwarding +export const ComboboxOption = CompoundSubComponent( + ({ ...props }: ComboboxOptionProps) => , + { + displayName: 'ComboboxOption', + key: CollectionToolbarFiltersSubComponentProperty.ComboboxOption, + }, +); diff --git a/packages/collection-toolbar/src/components/Filters/ComboboxOption/ComboboxOption.types.ts b/packages/collection-toolbar/src/components/Filters/ComboboxOption/ComboboxOption.types.ts new file mode 100644 index 0000000000..3c05c201b1 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/ComboboxOption/ComboboxOption.types.ts @@ -0,0 +1,3 @@ +import { InternalComboboxOptionProps } from '@leafygreen-ui/combobox'; + +export type ComboboxOptionProps = InternalComboboxOptionProps; diff --git a/packages/collection-toolbar/src/components/Filters/ComboboxOption/index.ts b/packages/collection-toolbar/src/components/Filters/ComboboxOption/index.ts new file mode 100644 index 0000000000..0f8d0b5b4d --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/ComboboxOption/index.ts @@ -0,0 +1,2 @@ +export { ComboboxOption } from './ComboboxOption'; +export { type ComboboxOptionProps } from './ComboboxOption.types'; diff --git a/packages/collection-toolbar/src/components/Filters/DatePicker/DatePicker.tsx b/packages/collection-toolbar/src/components/Filters/DatePicker/DatePicker.tsx new file mode 100644 index 0000000000..b8edf51729 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/DatePicker/DatePicker.tsx @@ -0,0 +1,24 @@ +import React, { ComponentType, forwardRef } from 'react'; + +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; +import { DatePicker as LGDatePicker } from '@leafygreen-ui/date-picker'; + +import { useCollectionToolbarContext } from '../../../Context/CollectionToolbarProvider'; +import { CollectionToolbarFiltersSubComponentProperty } from '../share.types'; + +import { DatePickerProps } from './DatePicker.types'; + +export const DatePicker = CompoundSubComponent( + // eslint-disable-next-line react/display-name + forwardRef(({ ...props }, fwdRef) => { + const { size } = useCollectionToolbarContext(); + + return ; + // Cast required for React 17: propTypes WeakValidationMap can't reconcile + // discriminated unions in ForwardRefExoticComponent + }) as ComponentType, + { + displayName: 'DatePicker', + key: CollectionToolbarFiltersSubComponentProperty.DatePicker, + }, +); diff --git a/packages/collection-toolbar/src/components/Filters/DatePicker/DatePicker.types.ts b/packages/collection-toolbar/src/components/Filters/DatePicker/DatePicker.types.ts new file mode 100644 index 0000000000..977b3ad5f3 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/DatePicker/DatePicker.types.ts @@ -0,0 +1,8 @@ +import { DatePickerProps as LGDatePickerProps } from '@leafygreen-ui/date-picker'; + +import { DistributiveOmit } from '../../../shared.types'; + +export type DatePickerProps = DistributiveOmit< + LGDatePickerProps, + 'size' | 'darkMode' +>; diff --git a/packages/collection-toolbar/src/components/Filters/DatePicker/index.ts b/packages/collection-toolbar/src/components/Filters/DatePicker/index.ts new file mode 100644 index 0000000000..f60e8bb166 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/DatePicker/index.ts @@ -0,0 +1,2 @@ +export { DatePicker } from './DatePicker'; +export type { DatePickerProps } from './DatePicker.types'; diff --git a/packages/collection-toolbar/src/components/Filters/Filters.spec.tsx b/packages/collection-toolbar/src/components/Filters/Filters.spec.tsx new file mode 100644 index 0000000000..97fb0539ff --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/Filters.spec.tsx @@ -0,0 +1,489 @@ +import React, { createRef } from 'react'; +import { render, screen } from '@testing-library/react'; + +import { consoleOnce } from '@leafygreen-ui/lib'; + +import { CollectionToolbarProvider } from '../../Context/CollectionToolbarProvider'; +import { Size, Variant } from '../../shared.types'; +import { getTestUtils } from '../../testing/getTestUtils'; +import { getLgIds } from '../../utils'; + +import { Filters } from './Filters'; +import { getIsFilterCountValid, MAX_FILTER_COUNT } from './utils'; + +jest.mock('@leafygreen-ui/lib', () => ({ + ...jest.requireActual('@leafygreen-ui/lib'), + consoleOnce: { + error: jest.fn(), + warn: jest.fn(), + log: jest.fn(), + }, +})); + +const lgIds = getLgIds(); +const { getFilters } = getTestUtils(); + +interface RenderFiltersProps extends React.ComponentProps { + children?: React.ReactNode; + size?: Size; + variant?: Variant; +} + +const renderFilters = (props: RenderFiltersProps = {}) => { + const { + children, + size = Size.Default, + variant = Variant.Default, + ...rest + } = props; + + return render( + + {children} + , + ); +}; + +describe('packages/collection-toolbar/components/Filters', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + test('renders container with correct data-lgid', () => { + renderFilters(); + const filtersContainer = getFilters(); + expect(filtersContainer).toBeInTheDocument(); + expect(filtersContainer).toHaveAttribute('data-lgid', lgIds.filters); + }); + + test('renders as a div element', () => { + renderFilters(); + const filtersContainer = getFilters(); + expect(filtersContainer.tagName).toBe('DIV'); + }); + }); + + describe('props & styling', () => { + test('applies className prop to container', () => { + renderFilters({ className: 'custom-class' }); + const filtersContainer = getFilters(); + expect(filtersContainer).toHaveClass('custom-class'); + }); + + test('spreads additional props to container element', () => { + renderFilters({ + 'aria-label': 'Filter controls', + id: 'custom-id', + } as React.ComponentProps); + + const filtersContainer = getFilters(); + expect(filtersContainer).toHaveAttribute('aria-label', 'Filter controls'); + expect(filtersContainer).toHaveAttribute('id', 'custom-id'); + }); + + test('forwards ref to the container element', () => { + const ref = createRef(); + render( + + + , + ); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current).toHaveAttribute('data-lgid', lgIds.filters); + }); + }); + + describe('child filtering', () => { + test('renders Filters.TextInput children', () => { + renderFilters({ + children: , + }); + expect( + screen.getByRole('textbox', { name: 'Search text' }), + ).toBeInTheDocument(); + }); + + test('renders Filters.Select with Option children', () => { + renderFilters({ + children: ( + + + Option 1 + + + Option 2 + + + ), + }); + + expect( + screen.getByRole('button', { name: /Select option/i }), + ).toBeInTheDocument(); + expect(screen.getByText('Option 1')).toBeInTheDocument(); + }); + + test('renders Filters.SegmentedControl children', () => { + renderFilters({ + children: ( + + + Grid + + + List + + + ), + }); + expect(screen.getByText('Grid')).toBeInTheDocument(); + expect(screen.getByText('List')).toBeInTheDocument(); + }); + + test('renders Filters.NumberInput children', () => { + renderFilters({ + children: , + }); + expect(screen.getByLabelText('Enter number')).toBeInTheDocument(); + }); + + test('renders Filters.Combobox children', () => { + renderFilters({ + children: , + }); + expect(screen.getByLabelText('Search combobox')).toBeInTheDocument(); + }); + + test('renders Filters.DatePicker children', () => { + renderFilters({ + children: , + }); + expect(screen.getByLabelText('Select date')).toBeInTheDocument(); + }); + + test('renders multiple filter types together', () => { + renderFilters({ + children: ( + <> + + + + Option 1 + + + + + ), + }); + + expect( + screen.getByRole('textbox', { name: 'Search text' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Select option/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('spinbutton', { name: 'Enter number' }), + ).toBeInTheDocument(); + }); + + test('filters out non-filter children', () => { + renderFilters({ + children: ( + <> + +
Should not render
+ Also should not render + + ), + }); + + expect( + screen.getByRole('textbox', { name: 'Valid filter' }), + ).toBeInTheDocument(); + expect(screen.queryByTestId('invalid-child')).not.toBeInTheDocument(); + expect( + screen.queryByText('Also should not render'), + ).not.toBeInTheDocument(); + }); + }); + + describe('compound component', () => { + test('has the correct static property key for compound component identification', () => { + expect(Filters.displayName).toBe('Filters'); + }); + + test('exposes TextInput as a static property', () => { + expect(Filters.TextInput).toBeDefined(); + }); + + test('exposes Select as a static property', () => { + expect(Filters.Select).toBeDefined(); + }); + + test('exposes SegmentedControl as a static property', () => { + expect(Filters.SegmentedControl).toBeDefined(); + }); + + test('exposes NumberInput as a static property', () => { + expect(Filters.NumberInput).toBeDefined(); + }); + + test('exposes Combobox as a static property', () => { + expect(Filters.Combobox).toBeDefined(); + }); + + test('exposes DatePicker as a static property', () => { + expect(Filters.DatePicker).toBeDefined(); + }); + }); + + describe('filter count validation', () => { + test('logs error when Compact variant has more than 2 filters', () => { + renderFilters({ + variant: Variant.Compact, + children: ( + <> + + + + + ), + }); + + expect(consoleOnce.error).toHaveBeenCalledWith( + `CollectionToolbarFilters with ${ + Variant.Compact + } variant can only have up to ${ + MAX_FILTER_COUNT[Variant.Compact] + } filters`, + ); + }); + + test('does not log error when Compact variant has 2 or fewer filters', () => { + renderFilters({ + variant: Variant.Compact, + children: ( + <> + + + + ), + }); + + expect(consoleOnce.error).not.toHaveBeenCalled(); + }); + + test('does not log error when Default variant has 5 filters', () => { + renderFilters({ + variant: Variant.Default, + children: ( + <> + + + + + + + ), + }); + + expect(consoleOnce.error).not.toHaveBeenCalled(); + }); + + test('logs error when Default variant has more than 5 filters', () => { + renderFilters({ + variant: Variant.Default, + children: ( + <> + + + + + + + + ), + }); + + expect(consoleOnce.error).toHaveBeenCalledWith( + `CollectionToolbarFilters with ${ + Variant.Default + } variant can only have up to ${ + MAX_FILTER_COUNT[Variant.Default] + } filters`, + ); + }); + + test('logs error when Collapsible variant has more than 5 filters', () => { + renderFilters({ + variant: Variant.Collapsible, + children: ( + <> + + + + + + + + ), + }); + + expect(consoleOnce.error).toHaveBeenCalledWith( + `CollectionToolbarFilters with ${ + Variant.Collapsible + } variant can only have up to ${ + MAX_FILTER_COUNT[Variant.Collapsible] + } filters`, + ); + }); + }); + + // eslint-disable-next-line jest/no-disabled-tests + describe.skip('types', () => { + test('TextInput requires aria-label or label', () => { + <> + {/* @ts-expect-error - Missing aria-label or label */} + + + {/* Valid: aria-label provided */} + + + {/* Valid: label provided */} + + ; + }); + + test('Select requires aria-label or label', () => { + <> + {/* @ts-expect-error - Missing aria-label or label */} + + Option 1 + + + {/* Valid: aria-label provided */} + + Option 1 + + + {/* Valid: label provided */} + + Option 1 + + ; + }); + + test('NumberInput requires aria-label or label', () => { + <> + {/* @ts-expect-error - Missing aria-label or label */} + + + {/* Valid: aria-label provided */} + + + {/* Valid: label provided */} + + ; + }); + + test('Combobox requires aria-label or label', () => { + <> + {/* @ts-expect-error - Missing aria-label or label */} + + + {/* Valid: aria-label provided */} + + + {/* Valid: label provided */} + + ; + }); + + test('DatePicker requires aria-label or label', () => { + <> + {/* @ts-expect-error - Missing aria-label or label */} + + + {/* Valid: aria-label provided */} + + + {/* Valid: label provided */} + + ; + }); + }); +}); + +describe('packages/collection-toolbar/components/Filters/utils', () => { + describe('MAX_FILTER_COUNT', () => { + test('Compact variant has max of 2', () => { + expect(MAX_FILTER_COUNT[Variant.Compact]).toBe(2); + }); + + test('Default variant has max of 5', () => { + expect(MAX_FILTER_COUNT[Variant.Default]).toBe(5); + }); + + test('Collapsible variant has max of 5', () => { + expect(MAX_FILTER_COUNT[Variant.Collapsible]).toBe(5); + }); + }); + + describe('getIsFilterCountValid', () => { + describe('Compact variant', () => { + test('returns true for 0 filters', () => { + expect(getIsFilterCountValid(0, Variant.Compact)).toBe(true); + }); + + test('returns true for 1 filter', () => { + expect(getIsFilterCountValid(1, Variant.Compact)).toBe(true); + }); + + test('returns true for 2 filters (max allowed)', () => { + expect(getIsFilterCountValid(2, Variant.Compact)).toBe(true); + }); + + test('returns false for 3 filters (exceeds max)', () => { + expect(getIsFilterCountValid(3, Variant.Compact)).toBe(false); + }); + }); + + describe('Default variant', () => { + test('returns true for 0 filters', () => { + expect(getIsFilterCountValid(0, Variant.Default)).toBe(true); + }); + + test('returns true for 5 filters (max allowed)', () => { + expect(getIsFilterCountValid(5, Variant.Default)).toBe(true); + }); + + test('returns false for 6 filters (exceeds max)', () => { + expect(getIsFilterCountValid(6, Variant.Default)).toBe(false); + }); + }); + + describe('Collapsible variant', () => { + test('returns true for 0 filters', () => { + expect(getIsFilterCountValid(0, Variant.Collapsible)).toBe(true); + }); + + test('returns true for 5 filters (max allowed)', () => { + expect(getIsFilterCountValid(5, Variant.Collapsible)).toBe(true); + }); + + test('returns false for 6 filters (exceeds max)', () => { + expect(getIsFilterCountValid(6, Variant.Collapsible)).toBe(false); + }); + }); + }); +}); diff --git a/packages/collection-toolbar/src/components/Filters/Filters.styles.ts b/packages/collection-toolbar/src/components/Filters/Filters.styles.ts new file mode 100644 index 0000000000..5503029e9a --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/Filters.styles.ts @@ -0,0 +1,35 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { breakpoints, spacing } from '@leafygreen-ui/tokens'; + +import { Variant } from '../../shared.types'; + +export const baseStyles = css` + flex: 100%; + display: flex; + flex-flow: row wrap; + align-items: flex-end; + gap: ${spacing[200]}px; +`; + +const compactStyles = css` + flex: 1; + + @media only screen and (max-width: ${breakpoints.Tablet}px) { + flex: 100%; + } +`; + +export const getFiltersStyles = ({ + className, + variant, +}: { + className?: string; + variant?: Variant; +}) => + cx( + baseStyles, + { + [compactStyles]: variant === Variant.Compact, + }, + className, + ); diff --git a/packages/collection-toolbar/src/components/Filters/Filters.tsx b/packages/collection-toolbar/src/components/Filters/Filters.tsx new file mode 100644 index 0000000000..788af587b3 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/Filters.tsx @@ -0,0 +1,88 @@ +import React, { forwardRef, useMemo } from 'react'; + +import { + CompoundSubComponent, + findChildren, +} from '@leafygreen-ui/compound-component'; +import { consoleOnce } from '@leafygreen-ui/lib'; + +import { useCollectionToolbarContext } from '../../Context/CollectionToolbarProvider'; +import { CollectionToolbarSubComponentProperty } from '../../shared.types'; + +import { Combobox } from './Combobox'; +import { ComboboxOption } from './ComboboxOption'; +import { DatePicker } from './DatePicker'; +import { getFiltersStyles } from './Filters.styles'; +import { FiltersProps } from './Filters.types'; +import { NumberInput } from './NumberInput'; +import { SegmentedControl } from './SegmentedControl'; +import { SegmentedControlOption } from './SegmentedControlOption'; +import { Select } from './Select'; +import { SelectOption } from './SelectOption'; +import { CollectionToolbarFiltersSubComponentProperty } from './share.types'; +import { TextInput } from './TextInput'; +import { getIsFilterCountValid, MAX_FILTER_COUNT } from './utils'; + +/** + * Filters component + * + * @param className - Class name to apply to the component + * @param children - Children to render + * @param props - Props to apply to the component + * @returns React element + * + * @note Default and Collapsible variants allow up to 5 filters. + * @note Compact variant allows up to 2 filters. + */ +export const Filters = CompoundSubComponent( + // eslint-disable-next-line react/display-name + forwardRef( + ({ className, children, ...props }, fwdRef) => { + const { lgIds, variant } = useCollectionToolbarContext(); + + const filterComponents = findChildren(children, [ + CollectionToolbarFiltersSubComponentProperty.NumberInput, + CollectionToolbarFiltersSubComponentProperty.Select, + CollectionToolbarFiltersSubComponentProperty.SegmentedControl, + CollectionToolbarFiltersSubComponentProperty.TextInput, + CollectionToolbarFiltersSubComponentProperty.Combobox, + CollectionToolbarFiltersSubComponentProperty.DatePicker, + ]); + + const isFilterCountValid = useMemo( + () => getIsFilterCountValid(filterComponents.length, variant), + [filterComponents.length, variant], + ); + + if (process.env.NODE_ENV !== 'production' && !isFilterCountValid) { + consoleOnce.error( + `CollectionToolbarFilters with ${variant} variant can only have up to ${MAX_FILTER_COUNT[variant]} filters`, + ); + } + + return ( +
+ {filterComponents} +
+ ); + }, + ), + { + displayName: 'Filters', + key: CollectionToolbarSubComponentProperty.Filters, + NumberInput, + Select, + SegmentedControl, + SegmentedControlOption, + SelectOption, + TextInput, + Combobox, + ComboboxOption, + DatePicker, + }, +); diff --git a/packages/collection-toolbar/src/components/Filters/Filters.types.ts b/packages/collection-toolbar/src/components/Filters/Filters.types.ts new file mode 100644 index 0000000000..123cd0cc0c --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/Filters.types.ts @@ -0,0 +1,3 @@ +import { ComponentPropsWithRef } from 'react'; + +export interface FiltersProps extends ComponentPropsWithRef<'div'> {} diff --git a/packages/collection-toolbar/src/components/Filters/NumberInput/NumberInput.styles.ts b/packages/collection-toolbar/src/components/Filters/NumberInput/NumberInput.styles.ts new file mode 100644 index 0000000000..1f42202758 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/NumberInput/NumberInput.styles.ts @@ -0,0 +1,8 @@ +import { css, cx } from '@leafygreen-ui/emotion'; + +export const baseInputStyles = css` + width: auto; +`; + +export const getInputStyles = ({ className }: { className?: string }) => + cx(baseInputStyles, className); diff --git a/packages/collection-toolbar/src/components/Filters/NumberInput/NumberInput.tsx b/packages/collection-toolbar/src/components/Filters/NumberInput/NumberInput.tsx new file mode 100644 index 0000000000..274e22b67e --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/NumberInput/NumberInput.tsx @@ -0,0 +1,61 @@ +import React, { ComponentType, forwardRef } from 'react'; + +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; +import { NumberInput as LGNumberInput } from '@leafygreen-ui/number-input'; + +import { useCollectionToolbarContext } from '../../../Context/CollectionToolbarProvider'; +import { CollectionToolbarFiltersSubComponentProperty } from '../share.types'; + +import { getInputStyles } from './NumberInput.styles'; +import { NumberInputProps } from './NumberInput.types'; + +export const NumberInput = CompoundSubComponent( + // eslint-disable-next-line react/display-name + forwardRef( + ( + { + unitOptions, + unit, + onSelectChange, + selectClassName, + inputClassName, + ...rest + }, + fwdRef, + ) => { + const { size } = useCollectionToolbarContext(); + + // Handle the two variants of NumberInput separately to satisfy the union type + if (unitOptions && unitOptions.length > 0 && onSelectChange) { + return ( + + ); + } + + return ( + + ); + }, + // Cast required for React 17: propTypes WeakValidationMap can't reconcile + // discriminated unions in ForwardRefExoticComponent + ) as ComponentType, + { + displayName: 'NumberInput', + key: CollectionToolbarFiltersSubComponentProperty.NumberInput, + }, +); diff --git a/packages/collection-toolbar/src/components/Filters/NumberInput/NumberInput.types.ts b/packages/collection-toolbar/src/components/Filters/NumberInput/NumberInput.types.ts new file mode 100644 index 0000000000..b01c34ee9d --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/NumberInput/NumberInput.types.ts @@ -0,0 +1,8 @@ +import { NumberInputProps as LGNumberInputProps } from '@leafygreen-ui/number-input'; + +import { DistributiveOmit } from '../../../shared.types'; + +export type NumberInputProps = DistributiveOmit< + LGNumberInputProps, + 'size' | 'darkMode' +>; diff --git a/packages/collection-toolbar/src/components/Filters/NumberInput/index.ts b/packages/collection-toolbar/src/components/Filters/NumberInput/index.ts new file mode 100644 index 0000000000..f734d3c5e8 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/NumberInput/index.ts @@ -0,0 +1,2 @@ +export { NumberInput } from './NumberInput'; +export type { NumberInputProps } from './NumberInput.types'; diff --git a/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControl.styles.ts b/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControl.styles.ts new file mode 100644 index 0000000000..8eda6e66de --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControl.styles.ts @@ -0,0 +1,12 @@ +import { css, cx } from '@leafygreen-ui/emotion'; + +const baseStyles = css` + flex-direction: column; + align-items: flex-start; +`; + +export const getSegmentedControlStyles = ({ + className, +}: { + className?: string; +}) => cx(baseStyles, className); diff --git a/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControl.tsx b/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControl.tsx new file mode 100644 index 0000000000..04e3fcac41 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControl.tsx @@ -0,0 +1,42 @@ +import React, { forwardRef } from 'react'; + +import { + CompoundSubComponent, + findChildren, +} from '@leafygreen-ui/compound-component'; +import { SegmentedControl as LGSegmentedControl } from '@leafygreen-ui/segmented-control'; + +import { useCollectionToolbarContext } from '../../../Context/CollectionToolbarProvider'; +import { CollectionToolbarFiltersSubComponentProperty } from '../share.types'; + +import { getSegmentedControlStyles } from './SegmentedControl.styles'; +import { SegmentedControlProps } from './SegmentedControl.types'; + +export const SegmentedControl = CompoundSubComponent( + // eslint-disable-next-line react/display-name + forwardRef( + ({ ref: _ref, className, children, ...props }, fwdRef) => { + const { size } = useCollectionToolbarContext(); + + const segmentedControlOptions = findChildren( + children, + CollectionToolbarFiltersSubComponentProperty.SegmentedControlOption, + ); + + return ( + + {segmentedControlOptions} + + ); + }, + ), + { + displayName: 'SegmentedControl', + key: CollectionToolbarFiltersSubComponentProperty.SegmentedControl, + }, +); diff --git a/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControl.types.ts b/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControl.types.ts new file mode 100644 index 0000000000..2391d2db23 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControl.types.ts @@ -0,0 +1,6 @@ +import { SegmentedControlProps as LGSegmentedControlProps } from '@leafygreen-ui/segmented-control'; + +export type SegmentedControlProps = Omit< + LGSegmentedControlProps, + 'size' | 'darkMode' +>; diff --git a/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControlOption/SegmentedControlOption.tsx b/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControlOption/SegmentedControlOption.tsx new file mode 100644 index 0000000000..be02347d5a --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControlOption/SegmentedControlOption.tsx @@ -0,0 +1,19 @@ +import React, { forwardRef } from 'react'; + +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; +import { SegmentedControlOption as LGSegmentedControlOption } from '@leafygreen-ui/segmented-control'; + +import { CollectionToolbarFiltersSubComponentProperty } from '../../share.types'; + +import { SegmentedControlOptionProps } from './SegmentedControlOption.types'; + +export const SegmentedControlOption = CompoundSubComponent( + // eslint-disable-next-line react/display-name + forwardRef((props, ref) => { + return ; + }), + { + displayName: 'SegmentedControlOption', + key: CollectionToolbarFiltersSubComponentProperty.SegmentedControlOption, + }, +); diff --git a/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControlOption/SegmentedControlOption.types.ts b/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControlOption/SegmentedControlOption.types.ts new file mode 100644 index 0000000000..fe05ddfd45 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControlOption/SegmentedControlOption.types.ts @@ -0,0 +1,3 @@ +import { BaseSegmentedControlOptionProps } from '@leafygreen-ui/segmented-control'; + +export type SegmentedControlOptionProps = BaseSegmentedControlOptionProps; diff --git a/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControlOption/index.ts b/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControlOption/index.ts new file mode 100644 index 0000000000..cb00d7a32b --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/SegmentedControl/SegmentedControlOption/index.ts @@ -0,0 +1,2 @@ +export { SegmentedControlOption } from './SegmentedControlOption'; +export type { SegmentedControlOptionProps } from './SegmentedControlOption.types'; diff --git a/packages/collection-toolbar/src/components/Filters/SegmentedControl/index.ts b/packages/collection-toolbar/src/components/Filters/SegmentedControl/index.ts new file mode 100644 index 0000000000..4dfee9a568 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/SegmentedControl/index.ts @@ -0,0 +1,2 @@ +export { SegmentedControl } from './SegmentedControl'; +export type { SegmentedControlProps } from './SegmentedControl.types'; diff --git a/packages/collection-toolbar/src/components/Filters/SegmentedControlOption/SegmentedControlOption.tsx b/packages/collection-toolbar/src/components/Filters/SegmentedControlOption/SegmentedControlOption.tsx new file mode 100644 index 0000000000..e577517d60 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/SegmentedControlOption/SegmentedControlOption.tsx @@ -0,0 +1,19 @@ +import React, { forwardRef } from 'react'; + +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; +import { SegmentedControlOption as LGSegmentedControlOption } from '@leafygreen-ui/segmented-control'; + +import { CollectionToolbarFiltersSubComponentProperty } from '../share.types'; + +import { SegmentedControlOptionProps } from './SegmentedControlOption.types'; + +export const SegmentedControlOption = CompoundSubComponent( + // eslint-disable-next-line react/display-name + forwardRef((props, ref) => { + return ; + }), + { + displayName: 'SegmentedControlOption', + key: CollectionToolbarFiltersSubComponentProperty.SegmentedControlOption, + }, +); diff --git a/packages/collection-toolbar/src/components/Filters/SegmentedControlOption/SegmentedControlOption.types.ts b/packages/collection-toolbar/src/components/Filters/SegmentedControlOption/SegmentedControlOption.types.ts new file mode 100644 index 0000000000..fe05ddfd45 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/SegmentedControlOption/SegmentedControlOption.types.ts @@ -0,0 +1,3 @@ +import { BaseSegmentedControlOptionProps } from '@leafygreen-ui/segmented-control'; + +export type SegmentedControlOptionProps = BaseSegmentedControlOptionProps; diff --git a/packages/collection-toolbar/src/components/Filters/SegmentedControlOption/index.ts b/packages/collection-toolbar/src/components/Filters/SegmentedControlOption/index.ts new file mode 100644 index 0000000000..cb00d7a32b --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/SegmentedControlOption/index.ts @@ -0,0 +1,2 @@ +export { SegmentedControlOption } from './SegmentedControlOption'; +export type { SegmentedControlOptionProps } from './SegmentedControlOption.types'; diff --git a/packages/collection-toolbar/src/components/Filters/Select/Select.tsx b/packages/collection-toolbar/src/components/Filters/Select/Select.tsx new file mode 100644 index 0000000000..5c73f98692 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/Select/Select.tsx @@ -0,0 +1,36 @@ +import React, { forwardRef } from 'react'; + +import { + CompoundSubComponent, + findChildren, +} from '@leafygreen-ui/compound-component'; +import { Select as LGSelect } from '@leafygreen-ui/select'; + +import { useCollectionToolbarContext } from '../../../Context/CollectionToolbarProvider'; +import { CollectionToolbarFiltersSubComponentProperty } from '../share.types'; + +import { SelectProps } from './Select.types'; + +export const Select = CompoundSubComponent( + // eslint-disable-next-line react/display-name + forwardRef( + ({ ref: _ref, children, ...rest }, fwdRef) => { + const { size } = useCollectionToolbarContext(); + + const selectOptions = findChildren( + children, + CollectionToolbarFiltersSubComponentProperty.SelectOption, + ); + + return ( + + {selectOptions} + + ); + }, + ), + { + displayName: 'Select', + key: CollectionToolbarFiltersSubComponentProperty.Select, + }, +); diff --git a/packages/collection-toolbar/src/components/Filters/Select/Select.types.ts b/packages/collection-toolbar/src/components/Filters/Select/Select.types.ts new file mode 100644 index 0000000000..b324c32192 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/Select/Select.types.ts @@ -0,0 +1,10 @@ +import { + DropdownWidthBasis as LGDropdownWidthBasis, + SelectProps as LGSelectProps, +} from '@leafygreen-ui/select'; + +import { DistributiveOmit } from '../../../shared.types'; + +export type SelectProps = DistributiveOmit; + +export const DropdownWidthBasis = LGDropdownWidthBasis; diff --git a/packages/collection-toolbar/src/components/Filters/Select/index.ts b/packages/collection-toolbar/src/components/Filters/Select/index.ts new file mode 100644 index 0000000000..b76bb507a9 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/Select/index.ts @@ -0,0 +1,2 @@ +export { Select } from './Select'; +export { DropdownWidthBasis, type SelectProps } from './Select.types'; diff --git a/packages/collection-toolbar/src/components/Filters/SelectOption/SelectOption.tsx b/packages/collection-toolbar/src/components/Filters/SelectOption/SelectOption.tsx new file mode 100644 index 0000000000..39fdc95267 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/SelectOption/SelectOption.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; +import { Option as LGOption } from '@leafygreen-ui/select'; + +import { CollectionToolbarFiltersSubComponentProperty } from '../share.types'; + +import { SelectOptionProps } from './SelectOption.types'; + +export const SelectOption = CompoundSubComponent( + (props: SelectOptionProps) => , + { + displayName: 'Option', // Need to name this as Option to make it compatible with the LG Select component + key: CollectionToolbarFiltersSubComponentProperty.SelectOption, + }, +); diff --git a/packages/collection-toolbar/src/components/Filters/SelectOption/SelectOption.types.ts b/packages/collection-toolbar/src/components/Filters/SelectOption/SelectOption.types.ts new file mode 100644 index 0000000000..ae79c4e3f4 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/SelectOption/SelectOption.types.ts @@ -0,0 +1,3 @@ +import { OptionProps } from '@leafygreen-ui/select'; + +export type SelectOptionProps = OptionProps; diff --git a/packages/collection-toolbar/src/components/Filters/SelectOption/index.ts b/packages/collection-toolbar/src/components/Filters/SelectOption/index.ts new file mode 100644 index 0000000000..2aba1ae170 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/SelectOption/index.ts @@ -0,0 +1,2 @@ +export { SelectOption } from './SelectOption'; +export type { SelectOptionProps } from './SelectOption.types'; diff --git a/packages/collection-toolbar/src/components/Filters/TextInput/TextInput.tsx b/packages/collection-toolbar/src/components/Filters/TextInput/TextInput.tsx new file mode 100644 index 0000000000..65f04c0e16 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/TextInput/TextInput.tsx @@ -0,0 +1,22 @@ +import React, { forwardRef } from 'react'; + +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; +import { TextInput as LGTextInput } from '@leafygreen-ui/text-input'; + +import { useCollectionToolbarContext } from '../../../Context/CollectionToolbarProvider'; +import { CollectionToolbarFiltersSubComponentProperty } from '../share.types'; + +import { TextInputProps } from './TextInput.types'; + +export const TextInput = CompoundSubComponent( + // eslint-disable-next-line react/display-name + forwardRef(({ ...props }, fwdRef) => { + const { size } = useCollectionToolbarContext(); + + return ; + }), + { + displayName: 'TextInput', + key: CollectionToolbarFiltersSubComponentProperty.TextInput, + }, +); diff --git a/packages/collection-toolbar/src/components/Filters/TextInput/TextInput.types.ts b/packages/collection-toolbar/src/components/Filters/TextInput/TextInput.types.ts new file mode 100644 index 0000000000..cdf9ea99a8 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/TextInput/TextInput.types.ts @@ -0,0 +1,8 @@ +import { TextInputProps as LGTextInputProps } from '@leafygreen-ui/text-input'; + +import { DistributiveOmit } from '../../../shared.types'; + +export type TextInputProps = DistributiveOmit< + LGTextInputProps, + 'sizeVariant' | 'darkMode' +>; diff --git a/packages/collection-toolbar/src/components/Filters/TextInput/index.ts b/packages/collection-toolbar/src/components/Filters/TextInput/index.ts new file mode 100644 index 0000000000..51b1de00e8 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/TextInput/index.ts @@ -0,0 +1,2 @@ +export { TextInput } from './TextInput'; +export type { TextInputProps } from './TextInput.types'; diff --git a/packages/collection-toolbar/src/components/Filters/index.ts b/packages/collection-toolbar/src/components/Filters/index.ts new file mode 100644 index 0000000000..6b8cefd464 --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/index.ts @@ -0,0 +1,17 @@ +export { Combobox, type ComboboxProps } from './Combobox'; +export { ComboboxOption, type ComboboxOptionProps } from './ComboboxOption'; +export { DatePicker, type DatePickerProps } from './DatePicker'; +export { Filters } from './Filters'; +export type { FiltersProps } from './Filters.types'; +export { NumberInput, type NumberInputProps } from './NumberInput'; +export { + SegmentedControl, + type SegmentedControlProps, +} from './SegmentedControl'; +export { + SegmentedControlOption, + type SegmentedControlOptionProps, +} from './SegmentedControlOption'; +export { DropdownWidthBasis, Select, type SelectProps } from './Select'; +export { SelectOption, type SelectOptionProps } from './SelectOption'; +export { TextInput, type TextInputProps } from './TextInput'; diff --git a/packages/collection-toolbar/src/components/Filters/share.types.ts b/packages/collection-toolbar/src/components/Filters/share.types.ts new file mode 100644 index 0000000000..1ed7bd134f --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/share.types.ts @@ -0,0 +1,17 @@ +/** + * Static property names used to identify CollectionToolbarFilters compound components. + * These are implementation details for the compound component pattern and should not be exported. + */ +export const CollectionToolbarFiltersSubComponentProperty = { + Select: 'isCollectionToolbarFiltersSelect', + SelectOption: 'isCollectionToolbarFiltersSelectOption', + DatePicker: 'isCollectionToolbarFiltersDatePicker', + SegmentedControl: 'isCollectionToolbarFiltersSegmentedControl', + SegmentedControlOption: 'isCollectionToolbarFiltersSegmentedControlOption', + TextInput: 'isCollectionToolbarFiltersTextInput', + NumberInput: 'isCollectionToolbarFiltersNumberInput', + Combobox: 'isCollectionToolbarFiltersCombobox', + ComboboxOption: 'isCollectionToolbarFiltersComboboxOption', +} as const; +export type CollectionToolbarFiltersSubComponentProperty = + (typeof CollectionToolbarFiltersSubComponentProperty)[keyof typeof CollectionToolbarFiltersSubComponentProperty]; diff --git a/packages/collection-toolbar/src/components/Filters/utils.ts b/packages/collection-toolbar/src/components/Filters/utils.ts new file mode 100644 index 0000000000..97c587603d --- /dev/null +++ b/packages/collection-toolbar/src/components/Filters/utils.ts @@ -0,0 +1,14 @@ +import { Variant } from '../../shared.types'; + +export const MAX_FILTER_COUNT: Record = { + [Variant.Compact]: 2, + [Variant.Collapsible]: 5, + [Variant.Default]: 5, +}; + +export const getIsFilterCountValid = ( + filterCount: number, + variant: Variant, +) => { + return filterCount <= MAX_FILTER_COUNT[variant]; +}; diff --git a/packages/collection-toolbar/src/components/SearchInput/SearchInput.spec.tsx b/packages/collection-toolbar/src/components/SearchInput/SearchInput.spec.tsx new file mode 100644 index 0000000000..8a1ea3cee7 --- /dev/null +++ b/packages/collection-toolbar/src/components/SearchInput/SearchInput.spec.tsx @@ -0,0 +1,114 @@ +import React, { createRef } from 'react'; +import { render } from '@testing-library/react'; + +import { CollectionToolbarProvider } from '../../Context/CollectionToolbarProvider'; +import { + CollectionToolbarSubComponentProperty, + Size, + Variant, +} from '../../shared.types'; +import { getLgIds } from '../../utils'; + +import { SearchInput } from './SearchInput'; + +const lgIds = getLgIds(); + +const renderSearchInput = ({ + size = Size.Default, + variant = Variant.Default, + darkMode = false, + 'aria-label': ariaLabel = 'Search', + ...props +}: { + size?: Size; + variant?: Variant; + darkMode?: boolean; +} & Partial> = {}) => { + return render( + + + , + ); +}; + +const getSearchInputElement = (container: HTMLElement) => { + return container.querySelector('input[type="search"]') as HTMLInputElement; +}; + +const getFormElement = (container: HTMLElement) => { + return container.querySelector('form') as HTMLFormElement; +}; + +describe('packages/collection-toolbar/components/SearchInput', () => { + describe('rendering', () => { + test('renders the search input', () => { + const { container } = renderSearchInput(); + const input = getSearchInputElement(container); + expect(input).toBeInTheDocument(); + }); + }); + + describe('context integration', () => { + test('applies data-lgid attribute from context', () => { + const { container } = renderSearchInput(); + const form = getFormElement(container); + expect(form).toHaveAttribute( + 'data-lgid', + 'lg-collection_toolbar-search-input', + ); + }); + }); + + describe('props', () => { + test('spreads additional props to SearchInput', () => { + const { container } = renderSearchInput({ + placeholder: 'Search items...', + }); + + const input = getSearchInputElement(container); + expect(input).toHaveAttribute('placeholder', 'Search items...'); + }); + + test('applies className prop with wrapper styles', () => { + const { container } = renderSearchInput({ className: 'custom-class' }); + const form = getFormElement(container); + expect(form).toHaveClass('custom-class'); + }); + + test('applies default empty aria-labelledby when not provided', () => { + const { container } = renderSearchInput({ + 'aria-label': undefined, + 'aria-labelledby': '', + }); + const searchboxWrapper = container.querySelector('[role="searchbox"]'); + expect(searchboxWrapper).toHaveAttribute('aria-labelledby', ''); + }); + }); + + describe('compound component', () => { + test('has the correct static property for compound component identification', () => { + expect( + SearchInput[CollectionToolbarSubComponentProperty.SearchInput], + ).toBe(true); + }); + }); + + describe('ref forwarding', () => { + test('forwards ref to the input element', () => { + const ref = createRef(); + const { container } = render( + + + , + ); + const input = getSearchInputElement(container); + expect(ref.current).not.toBeNull(); + expect(ref.current).toBe(input); + }); + }); +}); diff --git a/packages/collection-toolbar/src/components/SearchInput/SearchInput.styles.ts b/packages/collection-toolbar/src/components/SearchInput/SearchInput.styles.ts new file mode 100644 index 0000000000..8510fc4027 --- /dev/null +++ b/packages/collection-toolbar/src/components/SearchInput/SearchInput.styles.ts @@ -0,0 +1,37 @@ +import { css, cx } from '@leafygreen-ui/emotion'; + +import { Variant } from '../../shared.types'; +import { CUSTOM_BREAKPOINT } from '../constants'; + +export const baseStyles = css` + flex: 1; + min-width: 280px; + + @media only screen and (max-width: ${CUSTOM_BREAKPOINT}px) { + flex: 100%; + } +`; + +const compactStyles = css` + max-width: 280px; + + @media only screen and (max-width: ${CUSTOM_BREAKPOINT}px) { + max-width: 100%; + } +`; + +export const getSearchInputStyles = ({ + className, + variant, +}: { + className?: string; + variant?: Variant; +}) => { + return cx( + baseStyles, + { + [compactStyles]: variant === Variant.Compact, + }, + className, + ); +}; diff --git a/packages/collection-toolbar/src/components/SearchInput/SearchInput.tsx b/packages/collection-toolbar/src/components/SearchInput/SearchInput.tsx new file mode 100644 index 0000000000..98214db372 --- /dev/null +++ b/packages/collection-toolbar/src/components/SearchInput/SearchInput.tsx @@ -0,0 +1,36 @@ +import React, { ComponentType, forwardRef } from 'react'; + +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; +import { SearchInput as LGSearchInput } from '@leafygreen-ui/search-input'; + +import { useCollectionToolbarContext } from '../../Context/CollectionToolbarProvider'; +import { CollectionToolbarSubComponentProperty } from '../../shared.types'; + +import { getSearchInputStyles } from './SearchInput.styles'; +import { SearchInputProps } from './SearchInput.types'; + +export const SearchInput = CompoundSubComponent( + // eslint-disable-next-line react/display-name + forwardRef( + ({ className, ...props }, fwdRef) => { + const { size, darkMode, lgIds, variant } = useCollectionToolbarContext(); + + return ( + + ); + }, + // Cast required: TypeScript cannot reconcile ForwardRefExoticComponent's propTypes + // with the AriaLabelProps discriminated union (aria-label OR aria-labelledby required) + ) as ComponentType, + { + displayName: 'SearchInput', + key: CollectionToolbarSubComponentProperty.SearchInput, + }, +); diff --git a/packages/collection-toolbar/src/components/SearchInput/SearchInput.types.ts b/packages/collection-toolbar/src/components/SearchInput/SearchInput.types.ts new file mode 100644 index 0000000000..2e8f884665 --- /dev/null +++ b/packages/collection-toolbar/src/components/SearchInput/SearchInput.types.ts @@ -0,0 +1,12 @@ +import { AriaLabelProps } from '@leafygreen-ui/a11y'; +import { SearchInputProps as LGSearchInputProps } from '@leafygreen-ui/search-input'; + +/** + * Omit 'size' and 'darkMode' from base props (provided by context), + * then intersect with AriaLabelProps to preserve the discriminated union. + */ +export type SearchInputProps = Omit< + LGSearchInputProps, + 'size' | 'darkMode' | 'aria-label' | 'aria-labelledby' +> & + AriaLabelProps; diff --git a/packages/collection-toolbar/src/components/SearchInput/index.ts b/packages/collection-toolbar/src/components/SearchInput/index.ts new file mode 100644 index 0000000000..c7567b4e78 --- /dev/null +++ b/packages/collection-toolbar/src/components/SearchInput/index.ts @@ -0,0 +1,2 @@ +export { SearchInput } from './SearchInput'; +export { type SearchInputProps } from './SearchInput.types'; 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.styles.ts b/packages/collection-toolbar/src/components/Title/Title.styles.ts new file mode 100644 index 0000000000..33b3e5f3cb --- /dev/null +++ b/packages/collection-toolbar/src/components/Title/Title.styles.ts @@ -0,0 +1,19 @@ +import { css, cx } from '@leafygreen-ui/emotion'; + +import { Variant } from '../../shared.types'; + +const collapsibleTitleStyles = css` + flex: 1; +`; + +export const getTitleStyles = ({ + className, + variant, +}: { + className?: string; + variant?: Variant; +}) => { + return cx(className, { + [collapsibleTitleStyles]: variant === Variant.Collapsible, + }); +}; 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..820dd98304 --- /dev/null +++ b/packages/collection-toolbar/src/components/Title/Title.tsx @@ -0,0 +1,39 @@ +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 { getTitleStyles } from './Title.styles'; +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, variant, titleId } = 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..d9c83ac907 --- /dev/null +++ b/packages/collection-toolbar/src/components/Title/Title.types.ts @@ -0,0 +1,3 @@ +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/constants.ts b/packages/collection-toolbar/src/components/constants.ts new file mode 100644 index 0000000000..9c40e2bc2d --- /dev/null +++ b/packages/collection-toolbar/src/components/constants.ts @@ -0,0 +1,4 @@ +/** + * Ensures min-width for search input in compact variant. + */ +export const CUSTOM_BREAKPOINT = 960; diff --git a/packages/collection-toolbar/src/components/index.ts b/packages/collection-toolbar/src/components/index.ts new file mode 100644 index 0000000000..12c42225ad --- /dev/null +++ b/packages/collection-toolbar/src/components/index.ts @@ -0,0 +1,34 @@ +// Actions +export { + Actions, + type ActionsProps, + type ButtonProps, + ButtonVariant, + menuItemClassName, + type MenuItemProps, + type MenuProps, + MenuVariant, + type PaginationProps, +} from './Actions'; + +// Filters +export { + type ComboboxOptionProps, + type ComboboxProps, + type DatePickerProps, + DropdownWidthBasis, + Filters, + type FiltersProps, + type NumberInputProps, + type SegmentedControlOptionProps, + type SegmentedControlProps, + type SelectOptionProps, + type SelectProps, + type TextInputProps, +} from './Filters'; + +// SearchInput +export { SearchInput, type SearchInputProps } from './SearchInput'; + +// Title +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..42a7714517 --- /dev/null +++ b/packages/collection-toolbar/src/index.ts @@ -0,0 +1,28 @@ +export { + CollectionToolbar, + type CollectionToolbarProps, +} from './CollectionToolbar'; +export { + type ActionsProps, + type ButtonProps, + ButtonVariant, + type ComboboxOptionProps, + type ComboboxProps, + type DatePickerProps, + DropdownWidthBasis, + type FiltersProps, + menuItemClassName, + type MenuItemProps, + type MenuProps, + MenuVariant, + type NumberInputProps, + type PaginationProps, + type SearchInputProps, + type SegmentedControlOptionProps, + type SegmentedControlProps, + type SelectOptionProps, + type SelectProps, + type TextInputProps, + 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..be56f20295 --- /dev/null +++ b/packages/collection-toolbar/src/shared.types.ts @@ -0,0 +1,69 @@ +import { Size as ImportedSize } from '@leafygreen-ui/tokens'; + +/** + * Forces TypeScript to fully expand the type structure, + * avoiding references to internal (non-exported) types in declarations. + */ +export type Prettify = { [K in keyof T]: T[K] } & {}; + +/** + * Distributive Omit that preserves discriminated unions. + * Standard `Omit` flattens unions; this distributes over each union member. + * Uses `Prettify` to inline type structure and avoid declaration emit errors. + */ +export type DistributiveOmit = T extends unknown + ? Prettify> + : never; + +/** + * 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..587604aa4c --- /dev/null +++ b/packages/collection-toolbar/src/testing/getTestUtils.tsx @@ -0,0 +1,66 @@ +import { findByLgId, getByLgId, queryByLgId } from '@lg-tools/test-harnesses'; + +import { LgIdString } from '@leafygreen-ui/lib'; + +import { CollectionToolbarFiltersSubComponentProperty } from '../components/Filters/share.types'; +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 getActions = () => getByLgId!(lgIds.actions); + const findActions = () => findByLgId!(lgIds.actions); + const queryActions = () => queryByLgId!(lgIds.actions); + + const getPagination = () => getByLgId!(`${lgIds.pagination}-navigation`); + const findPagination = () => findByLgId!(`${lgIds.pagination}-navigation`); + const queryPagination = () => + queryByLgId!(`${lgIds.pagination}-navigation`); + + const getFilters = () => getByLgId!(lgIds.filters); + const findFilters = () => findByLgId!(lgIds.filters); + const queryFilters = () => queryByLgId!(lgIds.filters); + + const getFilterByType = ( + type: CollectionToolbarFiltersSubComponentProperty, + ) => getByLgId!(`${lgIds.filters}-${type}`); + const findFilterByType = ( + type: CollectionToolbarFiltersSubComponentProperty, + ) => findByLgId!(`${lgIds.filters}-${type}`); + const queryFilterByType = ( + type: CollectionToolbarFiltersSubComponentProperty, + ) => queryByLgId!(`${lgIds.filters}-${type}`); + + return { + findCollectionToolbar, + getCollectionToolbar, + queryCollectionToolbar, + getTitle, + findTitle, + queryTitle, + getActions, + findActions, + queryActions, + getPagination, + findPagination, + queryPagination, + getFilters, + findFilters, + queryFilters, + getFilterByType, + findFilterByType, + queryFilterByType, + }; +}; 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..922052a797 --- /dev/null +++ b/packages/collection-toolbar/src/testing/getTestUtils.types.ts @@ -0,0 +1,31 @@ +import { CollectionToolbarFiltersSubComponentProperty } from '../components/Filters/share.types'; + +export interface TestUtilsReturnType { + findCollectionToolbar: () => Promise; + getCollectionToolbar: () => T; + queryCollectionToolbar: () => T | null; + + getTitle: () => T; + findTitle: () => Promise; + queryTitle: () => T | null; + + getActions: () => T; + findActions: () => Promise; + queryActions: () => T | null; + + getPagination: () => T; + findPagination: () => Promise; + queryPagination: () => T | null; + + getFilters: () => T; + findFilters: () => Promise; + queryFilters: () => T | null; + + getFilterByType: (type: CollectionToolbarFiltersSubComponentProperty) => T; + findFilterByType: ( + type: CollectionToolbarFiltersSubComponentProperty, + ) => Promise; + queryFilterByType: ( + type: CollectionToolbarFiltersSubComponentProperty, + ) => 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..89ec579d8c --- /dev/null +++ b/packages/collection-toolbar/src/utils.ts @@ -0,0 +1,20 @@ +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`, + searchInput: `${root}-search-input`, + actions: `${root}-actions`, + button: `${root}-button`, + filters: `${root}-filters`, + 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..2a21e66019 --- /dev/null +++ b/packages/collection-toolbar/tsconfig.json @@ -0,0 +1,79 @@ +{ + "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": "../a11y" + }, + { + "path": "../button" + }, + { + "path": "../combobox" + }, + { + "path": "../compound-component" + }, + { + "path": "../date-picker" + }, + { + "path": "../emotion" + }, + { + "path": "../icon" + }, + { + "path": "../icon-button" + }, + { + "path": "../hooks" + }, + { + "path": "../leafygreen-provider" + }, + { + "path": "../lib" + }, + { + "path": "../menu" + }, + { + "path": "../number-input" + }, + { + "path": "../tokens" + }, + { + "path": "../pagination" + }, + { + "path": "../search-input" + }, + { + "path": "../segmented-control" + }, + { + "path": "../select" + }, + { + "path": "../text-input" + }, + { + "path": "../tooltip" + }, + { + "path": "../typography" + }, + { + "path": "../../tools/test-harnesses" + } + ] +} diff --git a/packages/combobox/src/ComboboxOption/index.ts b/packages/combobox/src/ComboboxOption/index.ts index d495dcda82..cb2cc3b809 100644 --- a/packages/combobox/src/ComboboxOption/index.ts +++ b/packages/combobox/src/ComboboxOption/index.ts @@ -1,5 +1,6 @@ export { ComboboxOption, InternalComboboxOption } from './ComboboxOption'; export { type ComboboxOptionProps, + type InternalComboboxOptionProps, type OptionObject, } from './ComboboxOption.types'; diff --git a/packages/combobox/src/index.ts b/packages/combobox/src/index.ts index 123b65747e..ee4273cab9 100644 --- a/packages/combobox/src/index.ts +++ b/packages/combobox/src/index.ts @@ -1,6 +1,10 @@ export { Combobox, type ComboboxProps, RenderMode } from './Combobox'; export { ComboboxGroup, type ComboboxGroupProps } from './ComboboxGroup'; -export { ComboboxOption, type ComboboxOptionProps } from './ComboboxOption'; +export { + ComboboxOption, + type ComboboxOptionProps, + type InternalComboboxOptionProps, +} from './ComboboxOption'; export { ComboboxSize, DropdownWidthBasis, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdfe890ab8..e3fb5b829d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1483,6 +1483,75 @@ importers: specifier: workspace:^ version: link:../../tools/build + packages/collection-toolbar: + dependencies: + '@leafygreen-ui/a11y': + specifier: workspace:^ + version: link:../a11y + '@leafygreen-ui/button': + specifier: workspace:^ + version: link:../button + '@leafygreen-ui/combobox': + specifier: workspace:^ + version: link:../combobox + '@leafygreen-ui/compound-component': + specifier: workspace:^ + version: link:../compound-component + '@leafygreen-ui/date-picker': + specifier: workspace:^ + version: link:../date-picker + '@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/number-input': + specifier: workspace:^ + version: link:../number-input + '@leafygreen-ui/pagination': + specifier: workspace:^ + version: link:../pagination + '@leafygreen-ui/search-input': + specifier: workspace:^ + version: link:../search-input + '@leafygreen-ui/segmented-control': + specifier: workspace:^ + version: link:../segmented-control + '@leafygreen-ui/select': + specifier: workspace:^ + version: link:../select + '@leafygreen-ui/text-input': + specifier: workspace:^ + version: link:../text-input + '@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 ea31416eac..bebf57d2e9 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',