Skip to content
Open
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: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@
"jest": "^30.3.0",
"jest-environment-jsdom": "^30.4.1",
"lint-staged": "^17.0.3",
"notistack": "^3.0.2",
"prettier": "^3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"react": "^19.2.7",
Expand Down Expand Up @@ -133,6 +132,7 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"notistack": "^3.0.2",
"@layer5/meshery-design-embed": "^0.6.0",
"@meshery/schemas": "^1.3.44",
"@sistent/mui-datatables": "^8.0.0",
Expand Down
167 changes: 167 additions & 0 deletions src/custom/ProgressBar/ProgressBar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { SnackbarProvider } from 'notistack';
import React, { useEffect, useState } from 'react';
import { Button } from '../../base';
import { SistentThemeProvider } from '../../theme';
import { ProgressBar } from './ProgressBar';
import { useProgressBar } from './useProgressBar';

const meta = {
title: 'Custom/ProgressBar',
component: ProgressBar
};

export default meta;

type Story = { name?: string; render: () => React.ReactElement };

const LinearDeterminateDemo = (): React.ReactElement => {
const { show, update, close } = useProgressBar();
const [activeKey, setActiveKey] = useState<string | number | null>(null);

const handleStart = () => {
const key = show({ message: 'Uploading files...', progress: 0, persist: true });
setActiveKey(key);
let p = 0;
const id = setInterval(() => {
p += 10;
if (p > 100) {
clearInterval(id);
close(key);
setActiveKey(null);
return;
}
update(key, { progress: p, message: `Uploading files... ${p}%` });
}, 400);
};

return (
<div style={{ display: 'flex', gap: 12 }}>
<Button variant="contained" onClick={handleStart} disabled={activeKey !== null}>
Start linear upload
</Button>
{activeKey !== null && (
<Button variant="outlined" onClick={() => activeKey !== null && close(activeKey)}>
Dismiss
</Button>
)}
</div>
);
};

const LinearIndeterminateDemo = (): React.ReactElement => {
const { show, close } = useProgressBar();
const [key, setKey] = useState<string | number | null>(null);
return (
<div style={{ display: 'flex', gap: 12 }}>
<Button
variant="contained"
onClick={() => {
const k = show({ message: 'Processing...', persist: true });
setKey(k);
}}
disabled={key !== null}
>
Show indeterminate
</Button>
{key !== null && (
<Button
variant="outlined"
onClick={() => {
close(key);
setKey(null);
}}
>
Dismiss
</Button>
)}
</div>
);
};

const CircularDemo = (): React.ReactElement => {
const { show, update, close } = useProgressBar();
const handleClick = () => {
const k = show({ message: 'Syncing...', progress: 0, variant: 'circular', persist: true });
let p = 0;
const id = setInterval(() => {
p += 15;
if (p > 100) {
clearInterval(id);
close(k);
return;
}
update(k, { progress: p });
}, 350);
};
return (
<Button variant="contained" onClick={handleClick}>
Start circular progress
</Button>
);
};

const StandaloneDemo = (): React.ReactElement => {
const [progress, setProgress] = useState(35);
useEffect(() => {
const id = setInterval(() => setProgress((prev) => (prev >= 100 ? 0 : prev + 5)), 600);
return () => clearInterval(id);
}, []);
return (
<SnackbarProvider maxSnack={3}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 520 }}>
<div style={{ border: '1px dashed #ccc', padding: 12, borderRadius: 8 }}>
<p style={{ margin: '0 0 8px', fontSize: 12, color: '#666' }}>
Standalone preview (without snackbar positioning):
Comment thread
KumarNirupam1 marked this conversation as resolved.
</p>
<ProgressBar
id="preview-linear"
message="Uploading design..."
progress={progress}
variant="linear"
/>
<div style={{ height: 12 }} />
<ProgressBar
id="preview-circular"
message="Syncing workspace..."
progress={progress}
variant="circular"
/>
<div style={{ height: 12 }} />
<ProgressBar id="preview-indeterminate" message="Processing..." variant="linear" />
</div>
</div>
</SnackbarProvider>
);
};

const WithProvider = (children: React.ReactElement): React.ReactElement => (
<SistentThemeProvider>
<SnackbarProvider maxSnack={3} anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}>
{children}
</SnackbarProvider>
</SistentThemeProvider>
);

export const LinearDeterminate: Story = {
name: 'Linear - determinate (updatable)',
render: () => WithProvider(<LinearDeterminateDemo />)
};

export const LinearIndeterminate: Story = {
name: 'Linear - indeterminate (persistent)',
render: () => WithProvider(<LinearIndeterminateDemo />)
};

export const Circular: Story = {
name: 'Circular - determinate (updatable)',
render: () => WithProvider(<CircularDemo />)
};

export const Standalone: Story = {
name: 'Standalone preview',
render: () => (
<SistentThemeProvider>
<StandaloneDemo />
</SistentThemeProvider>
)
};
125 changes: 125 additions & 0 deletions src/custom/ProgressBar/ProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { SnackbarContent, useSnackbar, type CustomContentProps } from 'notistack';
import React from 'react';
import { CircularProgress } from '../../base/CircularProgress';
import { LinearProgress } from '../../base/LinearProgress';
import { CloseIcon } from '../../icons';
import {
CloseButtonWrapper,
ProgressBarContent,
ProgressBarHeader,
ProgressBarLabel,
ProgressBarMessage,
ProgressBarTrack,
ProgressBarWrapper
} from './style';

export type ProgressBarVariant = 'linear' | 'circular';

export interface ProgressBarProps extends Omit<Partial<CustomContentProps>, 'variant'> {
id: CustomContentProps['id'];
/**
* Progress value 0-100. When undefined or non-finite (NaN/Infinity) the bar renders indeterminate.
* Values outside 0-100 are clamped.
* @default undefined (indeterminate)
*/
progress?: number;
/**
* Message / title shown alongside the progress indicator.
*/
message?: React.ReactNode;
/**
* Which base primitive to use for progress rendering.
* @default 'linear'
*/
variant?: ProgressBarVariant;
/**
* Show numeric percentage label next to the message (linear variant only).
* @default true when progress is determinate
*/
showProgressLabel?: boolean;
/**
* Allow dismiss via close button. When false the close button is hidden.
* @default true
*/
dismissible?: boolean;
/**
* Additional sx for the outer wrapper.
*/
sx?: React.ComponentProps<typeof ProgressBarWrapper>['sx'];
}

const clampProgress = (value: number): number => Math.min(100, Math.max(0, value));

export const ProgressBar = React.forwardRef<HTMLDivElement, ProgressBarProps>(
(
{
id,
progress,
message,
variant = 'linear',
showProgressLabel,
dismissible = true,
sx,
style,
...props
},
ref
) => {
const { closeSnackbar } = useSnackbar();

const isDeterminate = typeof progress === 'number' && Number.isFinite(progress);
const normalizedProgress = isDeterminate ? clampProgress(progress as number) : undefined;
const shouldShowLabel = (showProgressLabel ?? isDeterminate) && variant === 'linear';

const handleClose = React.useCallback(() => {
closeSnackbar(id);
}, [closeSnackbar, id]);

const renderProgress = () => {
if (variant === 'circular') {
if (isDeterminate) {
return <CircularProgress variant="determinate" value={normalizedProgress} size={28} />;
}
return <CircularProgress size={28} />;
}

if (isDeterminate) {
return <LinearProgress variant="determinate" value={normalizedProgress} />;
}
return <LinearProgress variant="indeterminate" />;
};

return (
<SnackbarContent ref={ref} role="alert" style={style} {...props}>
<ProgressBarWrapper sx={sx}>
{variant === 'circular' && renderProgress()}
<ProgressBarContent>
{(message || (shouldShowLabel && isDeterminate)) && (
<ProgressBarHeader>
{message && <ProgressBarMessage>{message}</ProgressBarMessage>}
{shouldShowLabel && isDeterminate && (
<ProgressBarLabel>{`${Math.round(normalizedProgress as number)}%`}</ProgressBarLabel>
)}
</ProgressBarHeader>
)}
{variant === 'linear' && <ProgressBarTrack>{renderProgress()}</ProgressBarTrack>}
</ProgressBarContent>
{dismissible && (
<CloseButtonWrapper
type="button"
aria-label="close"
onClick={handleClose}
data-testid="progress-bar-close"
>
<CloseIcon width={18} height={18} />
</CloseButtonWrapper>
)}
</ProgressBarWrapper>
</SnackbarContent>
);
}
);

ProgressBar.displayName = 'ProgressBar';

export default ProgressBar;
4 changes: 4 additions & 0 deletions src/custom/ProgressBar/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { ProgressBar, default } from './ProgressBar';
export type { ProgressBarProps, ProgressBarVariant } from './ProgressBar';
export { useProgressBar, default as useProgressBarDefault } from './useProgressBar';
export type { ShowProgressBarOptions, UseProgressBarReturn } from './useProgressBar';
73 changes: 73 additions & 0 deletions src/custom/ProgressBar/style.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { styled } from '@mui/material';

export const ProgressBarWrapper = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1.5),
padding: theme.spacing(1.5, 2),
minWidth: 300,
maxWidth: 480,
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary,
borderRadius: Number(theme.shape.borderRadius) * 1.5,
boxShadow: theme.shadows[6],
border: `1px solid ${theme.palette.divider}`
Comment thread
KumarNirupam1 marked this conversation as resolved.
}));

export const ProgressBarContent = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
flex: 1,
gap: theme.spacing(1),
minWidth: 0
}));

export const ProgressBarHeader = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: theme.spacing(1)
}));

export const ProgressBarMessage = styled('span')(({ theme }) => ({
fontSize: theme.typography.body2.fontSize,
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1
}));

export const ProgressBarLabel = styled('span')(({ theme }) => ({
fontSize: theme.typography.caption.fontSize,
color: theme.palette.text.secondary,
fontWeight: 500,
flexShrink: 0
}));

export const ProgressBarTrack = styled('div')({
width: '100%'
});

export const CloseButtonWrapper = styled('button')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
width: 28,
height: 28,
padding: 0,
border: 'none',
borderRadius: '50%',
backgroundColor: 'transparent',
color: theme.palette.text.secondary,
cursor: 'pointer',
'&:hover': {
backgroundColor: theme.palette.action.hover,
color: theme.palette.text.primary
},
'&:focus-visible': {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: 1
}
}));
Loading
Loading