diff --git a/package.json b/package.json index bf3a72522..c33185451 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/custom/ProgressBar/ProgressBar.stories.tsx b/src/custom/ProgressBar/ProgressBar.stories.tsx new file mode 100644 index 000000000..c9ad5f42b --- /dev/null +++ b/src/custom/ProgressBar/ProgressBar.stories.tsx @@ -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(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 ( +
+ + {activeKey !== null && ( + + )} +
+ ); +}; + +const LinearIndeterminateDemo = (): React.ReactElement => { + const { show, close } = useProgressBar(); + const [key, setKey] = useState(null); + return ( +
+ + {key !== null && ( + + )} +
+ ); +}; + +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 ( + + ); +}; + +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 ( + +
+
+

+ Standalone preview (without snackbar positioning): +

+ +
+ +
+ +
+
+ + ); +}; + +const WithProvider = (children: React.ReactElement): React.ReactElement => ( + + + {children} + + +); + +export const LinearDeterminate: Story = { + name: 'Linear - determinate (updatable)', + render: () => WithProvider() +}; + +export const LinearIndeterminate: Story = { + name: 'Linear - indeterminate (persistent)', + render: () => WithProvider() +}; + +export const Circular: Story = { + name: 'Circular - determinate (updatable)', + render: () => WithProvider() +}; + +export const Standalone: Story = { + name: 'Standalone preview', + render: () => ( + + + + ) +}; diff --git a/src/custom/ProgressBar/ProgressBar.tsx b/src/custom/ProgressBar/ProgressBar.tsx new file mode 100644 index 000000000..85fd2babd --- /dev/null +++ b/src/custom/ProgressBar/ProgressBar.tsx @@ -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, '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['sx']; +} + +const clampProgress = (value: number): number => Math.min(100, Math.max(0, value)); + +export const ProgressBar = React.forwardRef( + ( + { + 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 ; + } + return ; + } + + if (isDeterminate) { + return ; + } + return ; + }; + + return ( + + + {variant === 'circular' && renderProgress()} + + {(message || (shouldShowLabel && isDeterminate)) && ( + + {message && {message}} + {shouldShowLabel && isDeterminate && ( + {`${Math.round(normalizedProgress as number)}%`} + )} + + )} + {variant === 'linear' && {renderProgress()}} + + {dismissible && ( + + + + )} + + + ); + } +); + +ProgressBar.displayName = 'ProgressBar'; + +export default ProgressBar; diff --git a/src/custom/ProgressBar/index.tsx b/src/custom/ProgressBar/index.tsx new file mode 100644 index 000000000..1e5550b0d --- /dev/null +++ b/src/custom/ProgressBar/index.tsx @@ -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'; diff --git a/src/custom/ProgressBar/style.tsx b/src/custom/ProgressBar/style.tsx new file mode 100644 index 000000000..44828f9a7 --- /dev/null +++ b/src/custom/ProgressBar/style.tsx @@ -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}` +})); + +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 + } +})); diff --git a/src/custom/ProgressBar/useProgressBar.ts b/src/custom/ProgressBar/useProgressBar.ts new file mode 100644 index 000000000..4494fee25 --- /dev/null +++ b/src/custom/ProgressBar/useProgressBar.ts @@ -0,0 +1,151 @@ +import { useSnackbar, type OptionsObject, type SnackbarKey, type SnackbarMessage } from 'notistack'; +import React from 'react'; +import { ProgressBar, type ProgressBarProps } from './ProgressBar'; + +export interface ShowProgressBarOptions extends Omit< + OptionsObject, + 'content' | 'key' | 'variant' | 'action' +> { + /** + * Initial progress 0-100. Omit or pass a non-finite value (NaN/Infinity) for indeterminate. + */ + progress?: number; + message?: React.ReactNode; + /** + * Snackbar key. Auto-generated when not provided. + */ + key?: SnackbarKey; + variant?: ProgressBarProps['variant']; + showProgressLabel?: boolean; + dismissible?: boolean; + sx?: ProgressBarProps['sx']; +} + +export interface UseProgressBarReturn { + /** + * Show a persistent progress snackbar. Returns its key. + * The snackbar stays visible until dismissed or programmatically closed via `close(key)` — it does not auto-close when progress reaches 100. + */ + show: (options: ShowProgressBarOptions) => SnackbarKey; + /** + * Update progress/message of an open snackbar by key. Preserves the original + * ProgressBar configuration (variant, dismissible, sx, showProgressLabel) and + * snackbar options (persist, anchorOrigin, autoHideDuration, etc.) from the + * initial `show` call. + */ + update: ( + key: SnackbarKey, + options: Partial> & + Partial + ) => void; + /** + * Close snackbar by key. + */ + close: (key?: SnackbarKey) => void; +} + +export const useProgressBar = (): UseProgressBarReturn => { + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + const storeRef = React.useRef>(new Map()); + + const show = React.useCallback( + ({ + progress, + message, + key, + persist = true, + variant, + showProgressLabel, + dismissible, + sx, + ...rest + }: ShowProgressBarOptions): SnackbarKey => { + const storedOptions: ShowProgressBarOptions = { + progress, + message, + key, + persist, + variant, + showProgressLabel, + dismissible, + sx, + ...rest + }; + + const content = (id: SnackbarKey) => + React.createElement(ProgressBar, { + id, + progress, + message, + variant, + showProgressLabel, + dismissible, + sx + }); + + const returnedKey = enqueueSnackbar((message as SnackbarMessage) ?? '', { + key, + persist, + content: content as unknown as OptionsObject['content'], + ...rest + }); + + const storeKey = key ?? returnedKey; + storeRef.current.set(storeKey, storedOptions); + + return returnedKey; + }, + [enqueueSnackbar] + ); + + const update = React.useCallback( + ( + key: SnackbarKey, + options: Partial> & + Partial + ) => { + const stored = storeRef.current.get(key) ?? {}; + const merged: ShowProgressBarOptions = { + ...stored, + ...options, + key + }; + + const { progress, message, variant, showProgressLabel, dismissible, sx, persist, ...rest } = + merged; + + const content = (id: SnackbarKey) => + React.createElement(ProgressBar, { + id, + progress, + message, + variant, + showProgressLabel, + dismissible, + sx + }); + + enqueueSnackbar((message as SnackbarMessage) ?? '', { + key, + persist, + content: content as unknown as OptionsObject['content'], + ...rest + }); + + storeRef.current.set(key, merged); + }, + [enqueueSnackbar] + ); + + const close = React.useCallback( + (key?: SnackbarKey) => { + if (key !== undefined) storeRef.current.delete(key); + closeSnackbar(key); + }, + [closeSnackbar] + ); + + return { show, update, close }; +}; + +export default useProgressBar; diff --git a/src/custom/index.tsx b/src/custom/index.tsx index bff31634c..6bd1c9a3f 100644 --- a/src/custom/index.tsx +++ b/src/custom/index.tsx @@ -74,6 +74,8 @@ export { NavigationNavbar } from './NavigationNavbar'; export type { NavigationItem } from './NavigationNavbar'; export { Note } from './Note'; export { Panel } from './Panel'; +export { ProgressBar, useProgressBar } from './ProgressBar'; +export type { ProgressBarProps, ProgressBarVariant, ShowProgressBarOptions, UseProgressBarReturn } from './ProgressBar'; export { OpenLeaderBoardButton, PerformersSection, diff --git a/src/index.tsx b/src/index.tsx index 33a25a51f..0db04cbdb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -116,6 +116,15 @@ export { export { BottomSheet, type BottomSheetProps } from './custom/BottomSheet'; +export { + ProgressBar, + useProgressBar, + type ProgressBarProps, + type ProgressBarVariant, + type ShowProgressBarOptions, + type UseProgressBarReturn +} from './custom/ProgressBar'; + export { ActionButton, type ActionButtonProps, type Option } from './custom/ActionButton'; // Same nested-barrel dts-drop quirk as FeedbackButton above. The share/revoke