diff --git a/src/__testing__/BulkActionToolbar.test.tsx b/src/__testing__/BulkActionToolbar.test.tsx
new file mode 100644
index 000000000..713b60e58
--- /dev/null
+++ b/src/__testing__/BulkActionToolbar.test.tsx
@@ -0,0 +1,66 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import React from 'react';
+
+jest.mock('react-markdown', () => ({
+ __esModule: true,
+ default: ({ children }: { children: React.ReactNode }) =>
{children}
+}));
+
+jest.mock('remark-gfm', () => ({
+ __esModule: true,
+ default: () => {}
+}));
+
+jest.mock('rehype-raw', () => ({
+ __esModule: true,
+ default: () => {}
+}));
+
+import { BulkActionToolbar } from '../custom/BulkActionToolbar';
+import { SistentThemeProviderWithoutBaseLine } from '../theme';
+
+function renderWithTheme(ui: React.ReactElement) {
+ return render({ui});
+}
+
+describe('BulkActionToolbar', () => {
+ it('renders null when selectedCount is 0', () => {
+ const { container } = renderWithTheme();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders selected count and children when selectedCount > 0', () => {
+ renderWithTheme(
+
+
+
+ );
+
+ expect(screen.getByText('3 selected')).toBeTruthy();
+ expect(screen.getByTestId('custom-action')).toBeTruthy();
+ });
+
+ it('renders deselect button and handles callback', () => {
+ const onDeselectAll = jest.fn();
+ renderWithTheme();
+
+ const deselectButton = screen.getByTestId('deselect-all-button');
+ expect(deselectButton).toBeTruthy();
+
+ fireEvent.click(deselectButton);
+ expect(onDeselectAll).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders custom labels when provided', () => {
+ renderWithTheme(
+
+ );
+
+ expect(screen.getByText('3 items chosen')).toBeTruthy();
+ });
+});
diff --git a/src/custom/BulkActionToolbar/BulkActionToolbar.tsx b/src/custom/BulkActionToolbar/BulkActionToolbar.tsx
new file mode 100644
index 000000000..e8cdee35e
--- /dev/null
+++ b/src/custom/BulkActionToolbar/BulkActionToolbar.tsx
@@ -0,0 +1,72 @@
+import { styled } from '@mui/material/styles';
+import React from 'react';
+import { Box, IconButton, Toolbar, Typography } from '../../base';
+import { IndeterminateCheckBoxIcon } from '../../icons';
+import { useTheme } from '../../theme';
+import { CustomTooltip } from '../CustomTooltip';
+
+const StyledToolbar = styled(Toolbar)(({ theme }) => ({
+ backgroundColor:
+ theme.palette.mode === 'dark'
+ ? theme.palette.background.card
+ : theme.palette.background.secondary,
+ paddingLeft: theme.spacing(3),
+ paddingRight: theme.spacing(3),
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ width: '100%',
+ minHeight: '64px'
+}));
+
+export interface BulkActionToolbarProps {
+ selectedCount: number;
+ onDeselectAll?: () => void;
+ children?: React.ReactNode;
+ style?: React.CSSProperties;
+ 'data-testid'?: string;
+ deselectAllLabel?: string;
+ selectedLabel?: string;
+}
+
+export const BulkActionToolbar: React.FC = ({
+ selectedCount,
+ onDeselectAll,
+ children,
+ style = {},
+ 'data-testid': testId = 'bulk-action-toolbar',
+ deselectAllLabel = 'Deselect ALL',
+ selectedLabel = 'selected'
+}) => {
+ const theme = useTheme();
+
+ if (selectedCount <= 0) {
+ return null;
+ }
+
+ const iconFill = theme.palette.icon.default;
+
+ return (
+
+
+ {onDeselectAll && (
+
+
+
+
+
+ )}
+
+ {selectedCount} {selectedLabel}
+
+
+ {children}
+
+ );
+};
+
+export default BulkActionToolbar;
diff --git a/src/custom/BulkActionToolbar/index.ts b/src/custom/BulkActionToolbar/index.ts
new file mode 100644
index 000000000..621e880ed
--- /dev/null
+++ b/src/custom/BulkActionToolbar/index.ts
@@ -0,0 +1,2 @@
+export * from './BulkActionToolbar';
+export { default as BulkActionToolbar } from './BulkActionToolbar';
diff --git a/src/custom/index.tsx b/src/custom/index.tsx
index ba6916b0b..b6ed97af8 100644
--- a/src/custom/index.tsx
+++ b/src/custom/index.tsx
@@ -1,6 +1,7 @@
import { ActionButton } from './ActionButton';
import { BBChart } from './BBChart';
import { BookmarkNotification } from './BookmarkNotification';
+import BulkActionToolbar, { BulkActionToolbarProps } from './BulkActionToolbar';
import { Carousel } from './Carousel';
import CatalogFilter, { CatalogFilterProps } from './CatalogFilter/CatalogFilter';
import { ChapterCard } from './ChapterCard';
@@ -87,6 +88,7 @@ export {
ActionButton,
BBChart,
BookmarkNotification,
+ BulkActionToolbar,
Carousel,
CatalogCardDesignLogo,
CatalogFilter,
@@ -154,6 +156,7 @@ export { BasicMarkdown, RenderMarkdown };
export { CustomizedStepper, useStepper } from './Stepper';
export type {
+ BulkActionToolbarProps,
CatalogFilterProps,
ColView,
CustomColumn,
diff --git a/src/index.tsx b/src/index.tsx
index d7042f628..5bcbbacbc 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -23,6 +23,8 @@ export { FeedbackButton, type FeedbackComponentProps } from './custom/Feedback';
// `@sistent/mui-datatables` and would crash the dts build) precisely so this
// explicit re-export can force them into the published declaration bundle.
export { getCopyDeepLinkAction, type TableAction } from './custom/TableActions';
+
+export { BulkActionToolbar, type BulkActionToolbarProps } from './custom/BulkActionToolbar';
// Same nested-barrel dts-drop quirk as FeedbackButton above: without this
// explicit re-export the DangerConfirmationModal declarations (and its exported
// props types) are dropped from the bundled d.ts, breaking