Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/collection-toolbar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
"@leafygreen-ui/emotion": "workspace:^",
"@leafygreen-ui/lib": "workspace:^",
"@leafygreen-ui/tokens": "workspace:^",
"@leafygreen-ui/compound-component": "workspace:^",
"@leafygreen-ui/typography": "workspace:^",
"@lg-tools/test-harnesses": "workspace:^"
},
"peerDependencies": {
Expand Down
14 changes: 13 additions & 1 deletion packages/collection-toolbar/src/CollectionToolbar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ const meta: StoryMetaType<typeof CollectionToolbar> = {
variant: Object.values(Variant),
},
},
docs: {
description: {
component:
'CollectionToolbar is a component that displays a toolbar for a collection.',
},
},
},
argTypes: {
darkMode: storybookArgTypes.darkMode,
Expand All @@ -30,11 +36,17 @@ const meta: StoryMetaType<typeof CollectionToolbar> = {
},
args: {
children: 'Collection Toolbar',
size: {
control: 'select',
options: Object.values(Size),
},
},
};

export default meta;

export const LiveExample: StoryFn<typeof CollectionToolbar> = props => (
<CollectionToolbar {...props} />
<CollectionToolbar {...props}>
<CollectionToolbar.Title>Collection Title</CollectionToolbar.Title>
</CollectionToolbar>
);
Original file line number Diff line number Diff line change
@@ -1,11 +1,41 @@
import React from 'react';
import { render, screen } from '@testing-library/react';

import { Variant } from '../shared.types';
import { getTestUtils } from '../testing/getTestUtils';

import { CollectionToolbar } from '.';

describe('packages/collection-toolbar', () => {
test('renders correctly', () => {
render(<CollectionToolbar>Collection Toolbar</CollectionToolbar>);
expect(screen.getByText('Collection Toolbar')).toBeInTheDocument();
render(<CollectionToolbar />);
const utils = getTestUtils();
expect(utils.getCollectionToolbar()).toBeInTheDocument();
});

test('applies className to the root element', () => {
render(<CollectionToolbar className="test-class" />);
const utils = getTestUtils();
expect(utils.getCollectionToolbar()?.className).toContain('test-class');
});

describe('variant: collapsible', () => {
test('renders title when variant is collapsible', () => {
render(
<CollectionToolbar variant={Variant.Collapsible}>
<CollectionToolbar.Title>Test Title</CollectionToolbar.Title>
</CollectionToolbar>,
);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});

test('does not render title when variant is not collapsible', () => {
render(
<CollectionToolbar variant={Variant.Default}>
<CollectionToolbar.Title>Test Title</CollectionToolbar.Title>
</CollectionToolbar>,
);
expect(screen.queryByText('Test Title')).not.toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { css, cx } from '@leafygreen-ui/emotion';

import { Size, Variant } from './CollectionToolbar.types';
import { Size, Variant } from '../shared.types';

export const baseStyles = css``;

export const getCollectionToolbarStyles = ({
size,
variant,
className,
}: {
size?: Size;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,65 @@
import React from 'react';
import React, { forwardRef } from 'react';

import { getCollectionToolbarStyles } from './CollectionToolbar.styles';
import {
CollectionToolbarProps,
CompoundComponent,
findChild,
} from '@leafygreen-ui/compound-component';

import { Title } from '../components';
import { CollectionToolbarProvider } from '../Context/CollectionToolbarProvider';
import {
CollectionToolbarSubComponentProperty,
Size,
Variant,
} from './CollectionToolbar.types';
} from '../shared.types';
import { getLgIds } from '../utils';

import { getCollectionToolbarStyles } from './CollectionToolbar.styles';
import { CollectionToolbarProps } from './CollectionToolbar.types';

export const CollectionToolbar = CompoundComponent(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to include a forwardRef for this component?

Copy link
Collaborator

@stephl3 stephl3 Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is wrapped in forwardRef now, but we're still missing a ref being passed to the root <div>

// eslint-disable-next-line react/display-name
forwardRef<HTMLDivElement, CollectionToolbarProps>(
(
{
size = Size.Default,
variant = Variant.Default,
className,
children,
'data-lgid': dataLgId,
darkMode,
...rest
},
fwdRef,
) => {
const lgIds = getLgIds(dataLgId);
const title = findChild(
children,
CollectionToolbarSubComponentProperty.Title,
);

export function CollectionToolbar({
size = Size.Default,
variant = Variant.Default,
className,
children,
}: CollectionToolbarProps) {
return (
<div className={getCollectionToolbarStyles({ size, variant, className })}>
{children}
</div>
);
}
const showTitle = title && variant === Variant.Collapsible;

CollectionToolbar.displayName = 'CollectionToolbar';
return (
<CollectionToolbarProvider
darkMode={darkMode}
size={size}
lgIds={lgIds}
>
<div
data-lgid={lgIds.root}
className={getCollectionToolbarStyles({ size, variant, className })}
ref={fwdRef}
{...rest}
>
{showTitle && title}
</div>
</CollectionToolbarProvider>
);
},
),
{
displayName: 'CollectionToolbar',
Title,
},
);
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import React from 'react';
import { ComponentPropsWithRef } from 'react';

import { Size as ImportedSize } from '@leafygreen-ui/tokens';
import { DarkModeProps, LgIdProps } from '@leafygreen-ui/lib';

export const Variant = {
Compact: 'compact',
Default: 'default',
Collapsible: 'collapsible',
} as const;
export type Variant = (typeof Variant)[keyof typeof Variant];
import { Size, Variant } from '../shared.types';

export const Size = {
Default: ImportedSize.Default,
Small: ImportedSize.Small,
} as const;
export type Size = (typeof Size)[keyof typeof Size];

export interface CollectionToolbarProps {
size?: typeof ImportedSize.Default | typeof ImportedSize.Small;
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;
className?: string;
children?: React.ReactNode;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,2 @@
export { CollectionToolbar } from './CollectionToolbar';
export {
type CollectionToolbarProps,
Size,
Variant,
} from './CollectionToolbar.types';
export { type CollectionToolbarProps } from './CollectionToolbar.types';
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React, {
createContext,
PropsWithChildren,
useContext,
useMemo,
} from 'react';

import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider';
import { DarkModeProps } from '@leafygreen-ui/lib';

import { Size } from '../shared.types';
import { getLgIds, type GetLgIdsReturnType } from '../utils';

export interface CollectionToolbarContextProps extends DarkModeProps {
/**
* The size of the CollectionToolbar and it's sub-components.
*
* @default `'default'`
*/
size?: Size;
/**
* LGIDs for CollectionToolbar components
*/
lgIds: GetLgIdsReturnType;
}

export const CollectionToolbarContext =
createContext<CollectionToolbarContextProps>({
size: Size.Default,
lgIds: getLgIds(),
});

export const useCollectionToolbarContext = () => {
const context = useContext(CollectionToolbarContext);
if (!context)
throw new Error(
'useCollectionToolbarContext must be used within a CollectionToolbarProvider',
);
return useContext(CollectionToolbarContext);
};

export const CollectionToolbarProvider = ({
children,
darkMode,
size,
lgIds,
}: PropsWithChildren<CollectionToolbarContextProps>) => {
const collectionToolbarProviderData = useMemo(() => {
return {
size,
lgIds,
};
}, [size, lgIds]);

return (
<LeafyGreenProvider darkMode={darkMode}>
<CollectionToolbarContext.Provider value={collectionToolbarProviderData}>
{children}
</CollectionToolbarContext.Provider>
</LeafyGreenProvider>
);
};
87 changes: 87 additions & 0 deletions packages/collection-toolbar/src/components/Title/Title.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React, { createRef } from 'react';
import { render, screen } from '@testing-library/react';

import { CollectionToolbarSubComponentProperty } from '../../shared.types';
import { getTestUtils } from '../../testing/getTestUtils';

import Title from './Title';

// Mock H3 to properly forward refs for testing while preserving polymorphic behavior
jest.mock('@leafygreen-ui/typography', () => {
const React = require('react');
return {
// eslint-disable-next-line react/display-name
H3: React.forwardRef(
(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ as, ...props }: { as?: React.ElementType } & Record<string, any>,
ref: React.Ref<HTMLElement>,
) => {
const Component = as || 'h3';
return React.createElement(Component, { ...props, ref });
},
),
};
});

describe('packages/collection-toolbar/CollectionToolbar/components/Title', () => {
test('renders children correctly', () => {
render(<Title>Test Title</Title>);
const utils = getTestUtils();
expect(screen.getByText('Test Title')).toBeInTheDocument();
expect(utils.getTitle()).toBeInTheDocument();
});

test('renders as an h3 element as default', () => {
render(<Title>Test Title</Title>);
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
});

test('renders as a p element when using as prop', () => {
render(<Title as="p">Test Title</Title>);
expect(screen.getByText('Test Title').tagName).toBe('P');
});

test('applies className to the rendered element', () => {
render(<Title className="custom-class">Test Title</Title>);
expect(screen.getByText('Test Title')).toHaveClass('custom-class');
});

test('does not allow darkMode prop', () => {
// @ts-expect-error: darkMode prop is not allowed
render(<Title darkMode={true}>Test Title</Title>);
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', () => {
render(<Title>Test Title</Title>);
const titleElement = screen.getByText('Test Title');
expect(titleElement).toHaveAttribute(
'data-lgid',
'lg-collection_toolbar-title',
);
});

test('spreads additional HTML attributes to the element', () => {
render(
<Title data-testid="custom-title" aria-label="Custom label">
Test Title
</Title>,
);
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>();
render(<Title ref={ref}>Test Title</Title>);
expect(ref.current).not.toBeNull();
expect(ref.current).toBe(screen.getByText('Test Title'));
});
});
Loading
Loading