diff --git a/packages/admin-ui/package.json b/packages/admin-ui/package.json index e1329f8a659..3fa42b8232c 100644 --- a/packages/admin-ui/package.json +++ b/packages/admin-ui/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-tooltip": "^1.1.2", "@webiny/react-composition": "0.0.0", "@webiny/utils": "0.0.0", diff --git a/packages/admin-ui/src/Providers/Providers.tsx b/packages/admin-ui/src/Providers/Providers.tsx index 194ca280f26..9f5ffc94289 100644 --- a/packages/admin-ui/src/Providers/Providers.tsx +++ b/packages/admin-ui/src/Providers/Providers.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { ToastProvider, ToastViewport } from "~/Toast"; import { TooltipProvider } from "~/Tooltip"; export interface ProvidersProps { @@ -6,5 +7,10 @@ export interface ProvidersProps { } export const Providers = ({ children }: ProvidersProps) => { - return {children}; + return ( + + {children} + + + ); }; diff --git a/packages/admin-ui/src/Toast/Toast.stories.tsx b/packages/admin-ui/src/Toast/Toast.stories.tsx new file mode 100644 index 00000000000..fbc18ce4c42 --- /dev/null +++ b/packages/admin-ui/src/Toast/Toast.stories.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import { ReactComponent as SettingsIcon } from "@material-design-icons/svg/outlined/settings.svg"; +import type { Meta, StoryObj } from "@storybook/react"; +import { + Toast, + ToastProvider, + ToastViewport, + ToastAction, + ToastTitle, + ToastDescription +} from "./Toast"; +import { Button } from "~/Button"; + +const meta: Meta = { + title: "Components/Toast", + component: Toast, + tags: ["autodocs"], + parameters: { + layout: "fullscreen" + }, + argTypes: { + // Note: after upgrading to Storybook 8.X, use `fn`from `@storybook/test` to spy on the onOpenChange argument. + onOpenChange: { action: "onOpenChange" } + }, + decorators: [ + (Story, context) => { + const { args } = context; + const [open, setOpen] = React.useState(false); + const timerRef = React.useRef(0); + + React.useEffect(() => { + return () => clearTimeout(timerRef.current); + }, []); + + return ( + + + { + setOpen(false); + window.clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => { + setOpen(true); + }, 100); + }} + /> + { + setOpen(open); + if (typeof args.onOpenChange === "function") { + args.onOpenChange(open); + } + } + }} + /> + + + + ); + } + ] +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: + } +}; + +export const SubtleVariant: Story = { + args: { + ...Default.args, + variant: "subtle" + } +}; + +export const WithDescription: Story = { + args: { + ...Default.args, + description: + } +}; + +export const WithActions: Story = { + args: { + ...Default.args, + actions: [ + console.log("open", e)} + /> + ] + } +}; + +export const WithCustomIcon: Story = { + args: { + ...Default.args, + icon: + } +}; diff --git a/packages/admin-ui/src/Toast/Toast.tsx b/packages/admin-ui/src/Toast/Toast.tsx new file mode 100644 index 00000000000..3f19be848dd --- /dev/null +++ b/packages/admin-ui/src/Toast/Toast.tsx @@ -0,0 +1,203 @@ +import * as React from "react"; +import { ReactComponent as CloseIcon } from "@material-design-icons/svg/outlined/close.svg"; +import { ReactComponent as NotificationsIcon } from "@material-design-icons/svg/outlined/notifications_active.svg"; +import * as ToastPrimitives from "@radix-ui/react-toast"; +import { makeDecoratable } from "@webiny/react-composition"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "~/utils"; +import { Heading } from "~/Heading"; +import { Text } from "~/Text"; +import { Button } from "~/Button"; + +const ToastProvider = ToastPrimitives.Provider; + +/** + * Toast Viewport + */ +const ToastViewportBase = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastViewportBase.displayName = ToastPrimitives.Viewport.displayName; + +const ToastViewport = makeDecoratable("ToastViewport", ToastViewportBase); + +/** + * Toast Root + */ +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-start justify-start p-md gap-sm-extra self-stretch overflow-hidden rounded-md border-sm border-neutral-dimmed shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full", + { + variants: { + variant: { + default: "bg-neutral-dark text-neutral-light fill-neutral-base", + subtle: "bg-white fill-neutral-xstrong" + } + }, + defaultVariants: { + variant: "default" + } + } +); + +const ToastRootBase = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ); +}); +ToastRootBase.displayName = ToastPrimitives.Root.displayName; + +const ToastRoot = makeDecoratable("ToastRoot", ToastRootBase); + +/** + * Toast Action + */ +type ToastActionProps = ToastPrimitives.ToastActionProps & { + text: React.ReactNode; +}; + +const ToastActionBase = React.forwardRef< + React.ElementRef, + ToastActionProps +>(({ onClick, text, ...props }, ref) => ( + + + +)); +ToastActionBase.displayName = ToastPrimitives.Action.displayName; + +const ToastAction = makeDecoratable("ToastAction", ToastActionBase); + +/** + * Toast Close Icon + */ +const ToastCloseBase = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +ToastCloseBase.displayName = ToastPrimitives.Close.displayName; + +const ToastClose = makeDecoratable("ToastClose", ToastCloseBase); + +/** + * Toast Icon + */ +type ToastIconProps = { + icon?: React.ReactNode; +}; + +const ToastIconBase = ({ icon = }: ToastIconProps) => ( + + + {icon} + + +); + +const ToastIcon = makeDecoratable("ToastIcon", ToastIconBase); + +/** + * Toast Title + */ +type ToastTitleProps = Omit & { + text: React.ReactNode; +}; + +const ToastTitleBase = React.forwardRef< + React.ElementRef, + ToastTitleProps +>(({ text, ...props }, ref) => ( + + + +)); +ToastTitleBase.displayName = ToastPrimitives.Title.displayName; + +const ToastTitle = makeDecoratable("ToastTitle", ToastTitleBase); + +/** + * Toast Description + */ +type ToastDescriptionProps = Omit & { + text: React.ReactNode; +}; + +const ToastDescriptionBase = React.forwardRef< + React.ElementRef, + ToastDescriptionProps +>(({ text, ...props }, ref) => ( + + + +)); +ToastDescriptionBase.displayName = ToastPrimitives.Description.displayName; + +const ToastDescription = makeDecoratable("ToastDescription", ToastDescriptionBase); + +/** + * Toast + */ +type ToastRootProps = React.ComponentPropsWithoutRef; + +interface ToastProps extends Omit { + title: React.ReactElement; + description?: React.ReactElement; + icon?: React.ReactNode; + actions?: React.ReactElement | React.ReactElement[]; +} + +const ToastBase = ({ title, description, icon, actions, ...props }: ToastProps) => { + return ( + + + + {title} + {description && description} + {actions && actions} + + + + ); +}; + +const Toast = makeDecoratable("Toast", ToastBase); + +export { + type ToastRootProps, + type ToastActionProps, + type ToastProps, + Toast, + ToastAction, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport +}; diff --git a/packages/admin-ui/src/Toast/index.ts b/packages/admin-ui/src/Toast/index.ts new file mode 100644 index 00000000000..0652992f11e --- /dev/null +++ b/packages/admin-ui/src/Toast/index.ts @@ -0,0 +1 @@ +export * from "./Toast"; diff --git a/packages/admin-ui/src/index.ts b/packages/admin-ui/src/index.ts index 446f1683d2b..f8542b2a7c7 100644 --- a/packages/admin-ui/src/index.ts +++ b/packages/admin-ui/src/index.ts @@ -9,4 +9,5 @@ export * from "./Slider"; export * from "./Switch"; export * from "./Tabs"; export * from "./Text"; +export * from "./Toast"; export * from "./Tooltip"; diff --git a/packages/app-admin/src/base/Admin.tsx b/packages/app-admin/src/base/Admin.tsx index d3a7b123fae..e421094ed3c 100644 --- a/packages/app-admin/src/base/Admin.tsx +++ b/packages/app-admin/src/base/Admin.tsx @@ -2,12 +2,13 @@ import React from "react"; import { App } from "@webiny/app"; import { ThemeProvider } from "@webiny/app-theme"; import { WcpProvider } from "@webiny/app-wcp"; -import { CircularProgress } from "@webiny/ui/Progress"; import { Providers as UiProviders } from "@webiny/admin-ui"; +import { CircularProgress } from "@webiny/ui/Progress"; import { ApolloClientFactory, createApolloProvider } from "./providers/ApolloProvider"; import { Base } from "./Base"; import { createTelemetryProvider } from "./providers/TelemetryProvider"; import { createUiStateProvider } from "./providers/UiStateProvider"; +import { createAdminUiStateProvider } from "./providers/AdminUiStateProvider"; import { SearchProvider } from "./ui/Search"; import { UserMenuProvider } from "./ui/UserMenu"; import { NavigationProvider } from "./ui/Navigation"; @@ -23,6 +24,7 @@ export const Admin = ({ children, createApolloClient }: AdminProps) => { const ApolloProvider = createApolloProvider(createApolloClient); const TelemetryProvider = createTelemetryProvider(); const UiStateProvider = createUiStateProvider(); + const AdminUiStateProvider = createAdminUiStateProvider(); const DialogsProvider = createDialogsProvider(); return ( @@ -38,7 +40,8 @@ export const Admin = ({ children, createApolloClient }: AdminProps) => { UserMenuProvider, NavigationProvider, DialogsProvider, - IconPickerConfigProvider + IconPickerConfigProvider, + AdminUiStateProvider ]} > diff --git a/packages/app-admin/src/base/providers/AdminUiStateProvider.tsx b/packages/app-admin/src/base/providers/AdminUiStateProvider.tsx new file mode 100644 index 00000000000..951f517ff48 --- /dev/null +++ b/packages/app-admin/src/base/providers/AdminUiStateProvider.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { UiProvider } from "@webiny/app/contexts/Ui"; +import { createProvider } from "@webiny/app"; + +interface AdminUiStateProviderProps { + children: React.ReactNode; +} + +export const createAdminUiStateProvider = () => { + return createProvider(Component => { + return function AdminUiStateProvider({ children }: AdminUiStateProviderProps) { + return ( + + {children} + + ); + }; + }); +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 7012913a2fe..63770ddfbdf 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -28,6 +28,7 @@ "@rmwc/menu": "^14.2.2", "@rmwc/radio": "^14.2.2", "@rmwc/select": "^14.2.2", + "@rmwc/slider": "^14.2.2", "@rmwc/snackbar": "^14.2.2", "@rmwc/textfield": "^14.2.2", "@rmwc/top-app-bar": "^14.2.2", diff --git a/packages/ui/src/Snackbar/README.md b/packages/ui/src/Snackbar/README.md deleted file mode 100644 index 96b8b91fa91..00000000000 --- a/packages/ui/src/Snackbar/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Snackbar - -### Design - -https://material.io/design/components/snackbars.html - -### Description - -Use `Snackbar` component to display an informative or alert message and allow users to act upon it. - -### Import - -```js -import { Snackbar } from "@webiny/ui/Snackbar"; -``` diff --git a/packages/ui/src/Snackbar/Snackbar.tsx b/packages/ui/src/Snackbar/Snackbar.tsx index 883ff8a3f58..a18d320909f 100644 --- a/packages/ui/src/Snackbar/Snackbar.tsx +++ b/packages/ui/src/Snackbar/Snackbar.tsx @@ -1,30 +1,144 @@ -import React from "react"; -import { Snackbar as RmwcSnackbar, SnackbarAction, SnackbarProps } from "@rmwc/snackbar"; +import React, { useCallback, useMemo } from "react"; +import { Toast, ToastTitle, ToastAction, ToastActionProps } from "@webiny/admin-ui"; +import { ButtonProps } from "~/Button"; -type Props = SnackbarProps; +type CustomEventT = CustomEvent & React.SyntheticEvent; + +type SnackbarActionProps = ButtonProps & { + /** Content specified as a label prop. */ + label?: React.ReactNode | any; + /** An action returned in evt.detail.reason to the onClose handler. */ + action?: string; +}; + +type ToastActionWithDataActionProps = ToastActionProps & { + "data-action"?: string; +}; + +type SnackbarProps = { + open?: boolean; + /** A callback thats fired when the Snackbar shows. */ + onOpen?: (evt: CustomEventT) => void; + /** A callback thats fired when the Snackbar hides. evt.detail = { reason?: string } */ + onClose?: ( + evt: CustomEventT<{ + reason?: string; + }> + ) => void; + /** A string or other renderable JSX to be used as the message body. */ + message?: React.ReactNode; + /** One or more actions to add to the snackbar. */ + action?: + | React.ReactElement + | React.ReactElement[]; + /** Milliseconds to show the Snackbar for. Set to -1 to show indefinitely. */ + timeout?: number; + /** Places the action underneath the message text. */ + stacked?: boolean; + leading?: boolean; + dismissIcon?: boolean | React.ReactNode; + /** Whether or not your want clicking an action to close the Snackbar. */ + dismissesOnAction?: boolean; + /** An icon for the snackbar */ + icon?: React.ReactNode; +}; + +// Utility function to create a custom event +const createCustomEvent = (eventType: string, detail: T): CustomEvent => { + return new CustomEvent(eventType, { detail }); +}; /** - * Use Snackbar component to display an informative or alert message and allow users to act upon it. + * @deprecated This component is deprecated and will be removed in future releases. + * Please use the `Toast` component from the `@webiny/admin-ui` package instead. */ -class Snackbar extends React.Component { - public readonly container: HTMLElement | null = null; +const Snackbar = ({ onOpen, onClose, action, dismissesOnAction, open, message }: SnackbarProps) => { + // Function to map Snackbar actions to Toast actions + const renderActions = useMemo(() => { + if (!action) { + return []; + } - public constructor(props: Props) { - super(props); + const actions = Array.isArray(action) ? action : [action]; - this.container = document.getElementById("snackbar-container"); + return actions + .filter(action => React.isValidElement(action)) + .map((action, index) => { + return React.cloneElement(action, { + key: `action-${index}`, + onClick: (e: React.MouseEvent) => { + // Fire onClose event if dismissesOnAction is true + if (dismissesOnAction && typeof onClose === "function") { + const closeEvent = new CustomEvent("snackbarClose", { + detail: { + reason: action.props["data-action"] + } + }); + onClose(closeEvent as unknown as CustomEventT<{ reason?: string }>); + } - if (!this.container) { - this.container = document.createElement("div"); - this.container.setAttribute("id", "snackbar-container"); - const container: HTMLElement = this.container; - document.body && document.body.appendChild(container); - } - } + // Call any onClick handler defined on the action + if ( + React.isValidElement(action) && + typeof action.props?.onClick === "function" + ) { + action.props.onClick(e); + } + } + }); + }); + }, [action, dismissesOnAction, onClose]); + + // Function to map Snackbar actions to Toast actions + const handleOpenChange = useCallback( + (open: boolean) => { + // Create the event for opening + const openEvent = createCustomEvent("snackbarChange", { open }); - public override render(): React.ReactElement { - return ; - } -} + if (onOpen && open) { + onOpen(openEvent as unknown as CustomEventT); + } + + if (onClose && !open) { + // Create the event for closing with a specific reason + const closeEvent = createCustomEvent("snackbarChange", { + open, + reason: "closed" + }); + + onClose(closeEvent as unknown as CustomEventT<{ reason?: string }>); + } + }, + [onOpen, onClose] + ); + + return ( + } + actions={renderActions} + open={open} + onOpenChange={handleOpenChange} + /> + ); +}; + +/** + * @deprecated This component is deprecated and will be removed in future releases. + * Please use the `ToastAction` component from the `@webiny/admin-ui` package instead. + */ +const SnackbarAction = ({ + label, + action, + onClick +}: SnackbarActionProps): React.ReactElement => { + return ( + + ); +}; export { Snackbar, SnackbarAction }; diff --git a/yarn.lock b/yarn.lock index 7c731b558f6..0bb0700a592 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12674,6 +12674,36 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-toast@npm:^1.2.1": + version: 1.2.1 + resolution: "@radix-ui/react-toast@npm:1.2.1" + dependencies: + "@radix-ui/primitive": 1.1.0 + "@radix-ui/react-collection": 1.1.0 + "@radix-ui/react-compose-refs": 1.1.0 + "@radix-ui/react-context": 1.1.0 + "@radix-ui/react-dismissable-layer": 1.1.0 + "@radix-ui/react-portal": 1.1.1 + "@radix-ui/react-presence": 1.1.0 + "@radix-ui/react-primitive": 2.0.0 + "@radix-ui/react-use-callback-ref": 1.1.0 + "@radix-ui/react-use-controllable-state": 1.1.0 + "@radix-ui/react-use-layout-effect": 1.1.0 + "@radix-ui/react-visually-hidden": 1.1.0 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 1e29b68fc5725d170225a690d299935f7cf886c67a59fa325e2ff49a0e6f34c68c2e7adb07ef5ff67f28bfd3fe7cbcedec6a317b0019f949b5dbe405b4708c73 + languageName: node + linkType: hard + "@radix-ui/react-toggle-group@npm:1.1.0": version: 1.1.0 resolution: "@radix-ui/react-toggle-group@npm:1.1.0" @@ -17624,6 +17654,7 @@ __metadata: "@radix-ui/react-slot": ^1.1.0 "@radix-ui/react-switch": ^1.1.0 "@radix-ui/react-tabs": ^1.1.0 + "@radix-ui/react-toast": ^1.2.1 "@radix-ui/react-tooltip": ^1.1.2 "@storybook/addon-a11y": 7.6.20 "@storybook/addon-essentials": 7.6.20 @@ -21847,6 +21878,7 @@ __metadata: "@rmwc/menu": ^14.2.2 "@rmwc/radio": ^14.2.2 "@rmwc/select": ^14.2.2 + "@rmwc/slider": ^14.2.2 "@rmwc/snackbar": ^14.2.2 "@rmwc/textfield": ^14.2.2 "@rmwc/top-app-bar": ^14.2.2