From 06e963a748e684924fa3eb7f3ce414d85a2f9260 Mon Sep 17 00:00:00 2001 From: Sergey Garin Date: Mon, 27 Jun 2022 17:12:10 +0400 Subject: [PATCH 01/28] feat: notification component --- src/_internal/hooks/index.ts | 2 + src/_internal/hooks/use-event.ts | 19 ++++++ src/_internal/hooks/use-sync-ref.ts | 11 ++++ src/components/Root.tsx | 5 +- .../Notification/Notification.tsx | 51 ++++++++++++++++ .../Notification/NotificationAction.tsx | 29 +++++++++ .../Notification/NotificationCloseButton.tsx | 36 +++++++++++ .../Notification/NotificationDescription.tsx | 19 ++++++ .../Notification/NotificationFooter.tsx | 21 +++++++ .../Notification/NotificationHeader.tsx | 20 ++++++ .../Notification/NotificationIcon.tsx | 58 ++++++++++++++++++ .../NewNotifications/Notification/index.ts | 1 + .../Notifications.stories.tsx | 61 +++++++++++++++++++ .../NewNotifications/NotificationsBar.tsx | 5 ++ .../NewNotifications/NotificationsList.tsx | 9 +++ .../NotificationsProvider.tsx | 27 ++++++++ .../overlays/NewNotifications/index.ts | 8 +++ .../overlays/NewNotifications/types.ts | 59 ++++++++++++++++++ .../NewNotifications/use-notifications.ts | 8 +++ src/icons/Attention.tsx | 11 ++++ src/icons/Danger.tsx | 12 ++++ src/icons/Success.tsx | 11 ++++ src/icons/index.ts | 3 + src/tokens.ts | 2 +- 24 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 src/_internal/hooks/use-event.ts create mode 100644 src/_internal/hooks/use-sync-ref.ts create mode 100644 src/components/overlays/NewNotifications/Notification/Notification.tsx create mode 100644 src/components/overlays/NewNotifications/Notification/NotificationAction.tsx create mode 100644 src/components/overlays/NewNotifications/Notification/NotificationCloseButton.tsx create mode 100644 src/components/overlays/NewNotifications/Notification/NotificationDescription.tsx create mode 100644 src/components/overlays/NewNotifications/Notification/NotificationFooter.tsx create mode 100644 src/components/overlays/NewNotifications/Notification/NotificationHeader.tsx create mode 100644 src/components/overlays/NewNotifications/Notification/NotificationIcon.tsx create mode 100644 src/components/overlays/NewNotifications/Notification/index.ts create mode 100644 src/components/overlays/NewNotifications/Notifications.stories.tsx create mode 100644 src/components/overlays/NewNotifications/NotificationsBar.tsx create mode 100644 src/components/overlays/NewNotifications/NotificationsList.tsx create mode 100644 src/components/overlays/NewNotifications/NotificationsProvider.tsx create mode 100644 src/components/overlays/NewNotifications/index.ts create mode 100644 src/components/overlays/NewNotifications/types.ts create mode 100644 src/components/overlays/NewNotifications/use-notifications.ts create mode 100644 src/icons/Attention.tsx create mode 100644 src/icons/Danger.tsx create mode 100644 src/icons/Success.tsx create mode 100644 src/icons/index.ts diff --git a/src/_internal/hooks/index.ts b/src/_internal/hooks/index.ts index 2c5a414c..c011706a 100644 --- a/src/_internal/hooks/index.ts +++ b/src/_internal/hooks/index.ts @@ -1 +1,3 @@ export * from './use-deprecation-warning'; +export * from './use-event'; +export * from './use-sync-ref'; diff --git a/src/_internal/hooks/use-event.ts b/src/_internal/hooks/use-event.ts new file mode 100644 index 00000000..2ab7533b --- /dev/null +++ b/src/_internal/hooks/use-event.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useCallback } from 'react'; +import { useSyncRef } from './use-sync-ref'; + +/** + * useEvent shim from the latest React RFC. + * + * @see https://github.com/reactjs/rfcs/pull/220 + * @see https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md#internal-implementation + */ +export function useEvent< + Func extends (...args: Args) => Result, + Args extends Parameters = Parameters, + Result extends ReturnType = ReturnType, +>(callback: Func): (...args: Args) => Result { + const callbackRef = useSyncRef(callback); + + return useCallback((...args) => callbackRef.current(...args), []); +} diff --git a/src/_internal/hooks/use-sync-ref.ts b/src/_internal/hooks/use-sync-ref.ts new file mode 100644 index 00000000..2d3034fd --- /dev/null +++ b/src/_internal/hooks/use-sync-ref.ts @@ -0,0 +1,11 @@ +import { MutableRefObject, useLayoutEffect, useRef } from 'react'; + +export function useSyncRef(value: T): MutableRefObject { + const ref = useRef(value); + + useLayoutEffect(() => { + ref.current = value; + }); + + return ref; +} diff --git a/src/components/Root.tsx b/src/components/Root.tsx index d82b4782..a9db59bc 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -14,6 +14,7 @@ import { ModalProvider } from '@react-aria/overlays'; import { StyleSheetManager } from 'styled-components'; import { TOKENS } from '../tokens'; import { AlertDialogApiProvider } from './overlays/AlertDialog'; +import { NotificationsProvider } from './overlays/NewNotifications'; const RootElement = tasty({ id: 'cube-ui-kit-root', @@ -87,7 +88,9 @@ export const Root = (allProps: CubeRootProps) => { /> - {children} + + {children} + diff --git a/src/components/overlays/NewNotifications/Notification/Notification.tsx b/src/components/overlays/NewNotifications/Notification/Notification.tsx new file mode 100644 index 00000000..3ba0fdd7 --- /dev/null +++ b/src/components/overlays/NewNotifications/Notification/Notification.tsx @@ -0,0 +1,51 @@ +import { CubeNotificationProps } from '../types'; +import { tasty } from '../../../../tasty'; +import { NotificationAction } from './NotificationAction'; +import { NotificationIcon } from './NotificationIcon'; +import { NotificationHeader } from './NotificationHeader'; +import { NotificationDescription } from './NotificationDescription'; +import { NotificationFooter } from './NotificationFooter'; +import { useEvent } from '../../../../_internal'; +import { NotificationCloseButton } from './NotificationCloseButton'; + +const NotificationContainer = tasty({ + styles: { + position: 'relative', + display: 'grid', + width: 'auto', + padding: '1.5x 1x 1.5x 1.5x', + gridAreas: ` + "icon . header" + "icon . description" + "icon . footer" + `, + gridColumns: 'min-content 1x minmax(0, auto)', + fill: '#white', + }, +}); + +export function Notification(props: CubeNotificationProps) { + const { + onClose = () => {}, + type = 'attention', + actions, + header, + icon, + isClosable, + description, + } = props; + + const onCloseEvent = useEvent(onClose); + + return ( + + + {header && } + {description && } + {actions && } + {isClosable && } + + ); +} + +Notification.Action = NotificationAction; diff --git a/src/components/overlays/NewNotifications/Notification/NotificationAction.tsx b/src/components/overlays/NewNotifications/Notification/NotificationAction.tsx new file mode 100644 index 00000000..9366bc2c --- /dev/null +++ b/src/components/overlays/NewNotifications/Notification/NotificationAction.tsx @@ -0,0 +1,29 @@ +import { PropsWithChildren } from 'react'; +import { tasty } from '../../../../tasty'; +import { Button, CubeButtonProps } from '../../../actions'; + +export type NotificationActionProps = PropsWithChildren<{ + type?: 'primary' | 'secondary'; +}> & + Omit; + +const Action = tasty(Button, { + color: { '': '#purple-text', primary: '#purple-text', secondary: '#dark-03' }, +}); + +export function NotificationAction( + props: NotificationActionProps, +): JSX.Element { + const { children, type = 'primary', ...buttonProps } = props; + + return ( + + {children} + + ); +} diff --git a/src/components/overlays/NewNotifications/Notification/NotificationCloseButton.tsx b/src/components/overlays/NewNotifications/Notification/NotificationCloseButton.tsx new file mode 100644 index 00000000..12ec3e5a --- /dev/null +++ b/src/components/overlays/NewNotifications/Notification/NotificationCloseButton.tsx @@ -0,0 +1,36 @@ +import { CloseOutlined } from '@ant-design/icons'; +import { Button } from '../../../actions'; +import { tasty } from '../../../../tasty'; + +export type NotificationCloseButtonProps = { + onPress: () => void; +}; + +const CloseButton = tasty(Button, { + styles: { + position: 'absolute', + right: 0, + top: 0, + transform: 'translate(50%, -50%)', + width: '3.5x', + height: '3.5x', + fill: '#white', + boxShadow: '0px 0.5px 2px #shadow', + borderRadius: '50%', + color: '#dark-02', + }, +}); + +export function NotificationCloseButton( + props: NotificationCloseButtonProps, +): JSX.Element { + const { onPress } = props; + + return ( + onPress()} + icon={} + label="Close the notification" + /> + ); +} diff --git a/src/components/overlays/NewNotifications/Notification/NotificationDescription.tsx b/src/components/overlays/NewNotifications/Notification/NotificationDescription.tsx new file mode 100644 index 00000000..84b699a0 --- /dev/null +++ b/src/components/overlays/NewNotifications/Notification/NotificationDescription.tsx @@ -0,0 +1,19 @@ +import { memo } from 'react'; +import { tasty } from '../../../../tasty'; +import { Paragraph } from '../../../content/Paragraph'; + +export type NotificationDescriptionProps = { + description: string; +}; + +const Description = tasty(Paragraph, { + gridArea: 'description', +}); + +export const NotificationDescription = memo(function NotificationDescription( + props: NotificationDescriptionProps, +) { + const { description } = props; + + return {description}; +}); diff --git a/src/components/overlays/NewNotifications/Notification/NotificationFooter.tsx b/src/components/overlays/NewNotifications/Notification/NotificationFooter.tsx new file mode 100644 index 00000000..480e2d61 --- /dev/null +++ b/src/components/overlays/NewNotifications/Notification/NotificationFooter.tsx @@ -0,0 +1,21 @@ +import { CubeNotificationProps } from '../types'; +import { tasty } from '../../../../tasty'; +import { ButtonGroup } from '../../../actions'; + +interface NotificationFooterProps { + actions?: CubeNotificationProps['actions']; +} + +const FooterArea = tasty(ButtonGroup, { + gridArea: 'footer', + gap: '2x', + styles: { '&:not(:empty)': { margin: '1x top' } }, +}); + +export function NotificationFooter( + props: NotificationFooterProps, +): JSX.Element { + const { actions } = props; + + return {actions}; +} diff --git a/src/components/overlays/NewNotifications/Notification/NotificationHeader.tsx b/src/components/overlays/NewNotifications/Notification/NotificationHeader.tsx new file mode 100644 index 00000000..b3c0afd5 --- /dev/null +++ b/src/components/overlays/NewNotifications/Notification/NotificationHeader.tsx @@ -0,0 +1,20 @@ +import { Title } from '../../../content/Title'; +import { tasty } from '../../../../tasty'; + +export type NotificationHeaderProps = { + header: string; +}; + +const Header = tasty(Title, { gridArea: 'header', margin: '0.25x 0 0.5x' }); + +export function NotificationHeader( + props: NotificationHeaderProps, +): JSX.Element { + const { header } = props; + + return ( +
+ {header} +
+ ); +} diff --git a/src/components/overlays/NewNotifications/Notification/NotificationIcon.tsx b/src/components/overlays/NewNotifications/Notification/NotificationIcon.tsx new file mode 100644 index 00000000..281b72d5 --- /dev/null +++ b/src/components/overlays/NewNotifications/Notification/NotificationIcon.tsx @@ -0,0 +1,58 @@ +import { memo, ReactNode } from 'react'; +import { NotificationIconProps } from '../types'; +import { tasty } from '../../../../tasty'; +import { Danger, Success, Attention } from '../../../../icons'; + +const IconContainer = tasty({ + styles: { + boxSizing: 'border-box', + display: 'flex', + gridArea: 'icon', + fill: { + '': '#note-bg', + attention: '#note-bg', + success: '#success-bg', + danger: '#danger-bg', + }, + color: { + '': '#note-text', + attention: '#note-text', + success: '#success-text', + danger: '#danger-text', + }, + borderRadius: '0.5x', + width: '3x', + height: '3x', + padding: '0.5x', + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export const NotificationIcon = memo(function NotificationIcon( + props: NotificationIconProps, +): JSX.Element { + const { icon, type } = props; + + if (icon) { + return <>{icon}; + } + + return ( + + {iconsByType[type]} + + ); +}); + +const iconsByType: Record = { + attention: , + success: , + danger: , +}; diff --git a/src/components/overlays/NewNotifications/Notification/index.ts b/src/components/overlays/NewNotifications/Notification/index.ts new file mode 100644 index 00000000..ed80171a --- /dev/null +++ b/src/components/overlays/NewNotifications/Notification/index.ts @@ -0,0 +1 @@ +export * from './Notification'; diff --git a/src/components/overlays/NewNotifications/Notifications.stories.tsx b/src/components/overlays/NewNotifications/Notifications.stories.tsx new file mode 100644 index 00000000..bd16c9b7 --- /dev/null +++ b/src/components/overlays/NewNotifications/Notifications.stories.tsx @@ -0,0 +1,61 @@ +import { Meta, Story } from '@storybook/react'; +import { Notification } from './Notification'; +import { CubeNotificationProps } from './types'; +import { Button } from '../../actions'; +import { useNotifications } from './use-notifications'; +import { NotificationsList } from './NotificationsList'; + +export default { + title: 'Overlays/Notifications', + component: Notification, + args: { + header: 'Development mode available', + description: 'Edit and test your schema without affecting the production.', + }, + subcomponents: { NotificationAction: Notification.Action }, +} as Meta; + +const ActionTemplate: Story = (args) => { + const { notify } = useNotifications(); + + return ( + + ); +}; + +export const DefaultAction = ActionTemplate.bind({}); + +export const StandaloneNotification: Story = (args) => { + return ; +}; + +export const ClosableNotification = StandaloneNotification.bind({}); +ClosableNotification.args = { isClosable: true }; + +export const WithActions = StandaloneNotification.bind({}); +WithActions.args = { + actions: ( + <> + Activate + + Don't show this again + + + ), +}; + +export const List: Story = (args) => { + return ( + + + + + + ); +}; diff --git a/src/components/overlays/NewNotifications/NotificationsBar.tsx b/src/components/overlays/NewNotifications/NotificationsBar.tsx new file mode 100644 index 00000000..10a2e8fa --- /dev/null +++ b/src/components/overlays/NewNotifications/NotificationsBar.tsx @@ -0,0 +1,5 @@ +export type NotificationsBarProps = {}; + +export function NotificationsBar(props: NotificationsBarProps): JSX.Element { + return <>; +} diff --git a/src/components/overlays/NewNotifications/NotificationsList.tsx b/src/components/overlays/NewNotifications/NotificationsList.tsx new file mode 100644 index 00000000..72c0ffd1 --- /dev/null +++ b/src/components/overlays/NewNotifications/NotificationsList.tsx @@ -0,0 +1,9 @@ +import { Notification } from './Notification'; + +export type NotificationsListProps = {}; + +export function NotificationsList(props): JSX.Element { + return
; +} + +NotificationsList.Item = Notification; diff --git a/src/components/overlays/NewNotifications/NotificationsProvider.tsx b/src/components/overlays/NewNotifications/NotificationsProvider.tsx new file mode 100644 index 00000000..3b37e943 --- /dev/null +++ b/src/components/overlays/NewNotifications/NotificationsProvider.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext } from 'react'; +import invariant from 'tiny-invariant'; +import { NotificationsBar } from './NotificationsBar'; + +const NotificationsContext = createContext(null); + +export function useNotificationsContext() { + const context = useContext(NotificationsContext); + + invariant( + context !== null, + "You can't use Notifications outside of the component. Please, check if your component is descendant of component", + ); + + return context; +} + +export function NotificationsProvider(props) { + const { children, value } = props; + + return ( + + + {children} + + ); +} diff --git a/src/components/overlays/NewNotifications/index.ts b/src/components/overlays/NewNotifications/index.ts new file mode 100644 index 00000000..c9da7098 --- /dev/null +++ b/src/components/overlays/NewNotifications/index.ts @@ -0,0 +1,8 @@ +export * from './Notification'; +export * from './NotificationsProvider'; +export * from './use-notifications'; +export * from './NotificationsBar'; +export type { + CubeNotificationActionProps, + CubeNotificationProps, +} from './types'; diff --git a/src/components/overlays/NewNotifications/types.ts b/src/components/overlays/NewNotifications/types.ts new file mode 100644 index 00000000..ce33932a --- /dev/null +++ b/src/components/overlays/NewNotifications/types.ts @@ -0,0 +1,59 @@ +import { ReactNode, ReactElement, Key } from 'react'; +import { NotificationAction } from './Notification/NotificationAction'; + +export type NotificationType = 'success' | 'danger' | 'attention'; +export type NotificationActionType = ReactElement< + CubeNotificationActionProps, + typeof NotificationAction +>; + +export type CubeNotificationProps = { + /** + * @default 'attention' + */ + type?: NotificationType; + /** + * The delay before the notification hides (in milliseconds) If set to `null`, it will never dismiss. + */ + duration?: number | null; + /** + * ID of the notification. Mostly used when you need to prevent duplicate. By default, we generate a unique id for each notification + */ + id?: Key; + /** + * If true, notification will have the close button. + * @default true + */ + isClosable?: boolean; + onClose?: () => void; + /** + * Title of the notification + */ + header?: string; + description?: string; + /** + * Custom Icon for the notification + */ + icon?: ReactNode; + /** + * Custom Actions in the notification + */ + actions?: NotificationActionType | NotificationActionType[]; +} & (NotificationWithHeader | NotificationWithDescription); + +type NotificationWithHeader = { + header: string; +}; + +type NotificationWithDescription = { + description: string; +}; + +export type CubeNotificationActionProps = { + onPress: () => void; +}; + +export type NotificationIconProps = { + type: NotificationType; + icon?: ReactNode; +}; diff --git a/src/components/overlays/NewNotifications/use-notifications.ts b/src/components/overlays/NewNotifications/use-notifications.ts new file mode 100644 index 00000000..c417f2b1 --- /dev/null +++ b/src/components/overlays/NewNotifications/use-notifications.ts @@ -0,0 +1,8 @@ +import { useEvent } from '../../../_internal'; +import { CubeNotificationProps } from './types'; + +export function useNotifications() { + return { + notify: useEvent((props: CubeNotificationProps) => {}), + }; +} diff --git a/src/icons/Attention.tsx b/src/icons/Attention.tsx new file mode 100644 index 00000000..cb051d5c --- /dev/null +++ b/src/icons/Attention.tsx @@ -0,0 +1,11 @@ +export function Attention() { + return ( + + + + ); +} diff --git a/src/icons/Danger.tsx b/src/icons/Danger.tsx new file mode 100644 index 00000000..4bee761e --- /dev/null +++ b/src/icons/Danger.tsx @@ -0,0 +1,12 @@ +export function Danger() { + return ( + + + + + ); +} diff --git a/src/icons/Success.tsx b/src/icons/Success.tsx new file mode 100644 index 00000000..b1b7b1f4 --- /dev/null +++ b/src/icons/Success.tsx @@ -0,0 +1,11 @@ +export function Success() { + return ( + + + + ); +} diff --git a/src/icons/index.ts b/src/icons/index.ts new file mode 100644 index 00000000..cd116434 --- /dev/null +++ b/src/icons/index.ts @@ -0,0 +1,3 @@ +export * from './Danger'; +export * from './Attention'; +export * from './Success'; diff --git a/src/tokens.ts b/src/tokens.ts index 25598509..08b28118 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -51,7 +51,7 @@ const TOKENS = { 'minor-color': color('dark', 0.65), 'success-bg-color': 'rgba(41, 190, 110, .1)', 'note-bg-color': color('note', 0.1), - 'note-text-color': color('dark', 0.65), + 'note-text-color': color('note', 0.65), 'danger-bg-color': color('danger', 0.05), 'danger-bg-hover-color': color('danger', 0.1), 'primary-1': color('purple', 0.9), From 44fe89dd03adea6ebfe6c0adfeca2568fde5d83a Mon Sep 17 00:00:00 2001 From: Sergey Garin Date: Fri, 8 Jul 2022 20:25:24 +0400 Subject: [PATCH 02/28] feat: notification component --- .storybook/main.js | 15 ++ package.json | 5 +- src/_internal/hooks/index.ts | 1 + src/_internal/hooks/use-chained-callback.ts | 13 ++ src/components/actions/Button/Button.tsx | 2 +- .../overlays/NewNotifications/Bar/Bar.tsx | 60 ++++++ .../Bar/FloatingNotification.tsx | 36 ++++ .../Bar/TransitionComponent.tsx | 71 +++++++ .../overlays/NewNotifications/Bar/index.ts | 1 + .../Notification/Notification.tsx | 86 +++++++-- .../Notification/NotificationCloseButton.tsx | 29 ++- .../Notification/NotificationDescription.tsx | 12 +- .../Notification/NotificationHeader.tsx | 11 +- .../Notification/NotificationIcon.tsx | 2 +- .../NewNotifications/Notification/index.ts | 1 + .../NewNotifications/Notification/types.ts | 12 ++ .../Notifications.stories.tsx | 69 +++++-- .../NewNotifications/NotificationsBar.tsx | 5 - .../NotificationsDialogTrigger.tsx | 28 +++ .../NotificationsDialog/index.ts | 0 .../NewNotifications/NotificationsList.tsx | 77 +++++++- .../NotificationsProvider.tsx | 93 +++++++++- .../overlays/NewNotifications/index.ts | 17 +- .../overlays/NewNotifications/timer.ts | 43 +++++ .../overlays/NewNotifications/types.ts | 17 +- .../NewNotifications/use-notifications.ts | 8 - src/icons/Cross.tsx | 11 ++ src/icons/index.ts | 1 + yarn.lock | 174 +++++++++++++++++- 29 files changed, 803 insertions(+), 97 deletions(-) create mode 100644 src/_internal/hooks/use-chained-callback.ts create mode 100644 src/components/overlays/NewNotifications/Bar/Bar.tsx create mode 100644 src/components/overlays/NewNotifications/Bar/FloatingNotification.tsx create mode 100644 src/components/overlays/NewNotifications/Bar/TransitionComponent.tsx create mode 100644 src/components/overlays/NewNotifications/Bar/index.ts create mode 100644 src/components/overlays/NewNotifications/Notification/types.ts delete mode 100644 src/components/overlays/NewNotifications/NotificationsBar.tsx create mode 100644 src/components/overlays/NewNotifications/NotificationsDialog/NotificationsDialogTrigger.tsx create mode 100644 src/components/overlays/NewNotifications/NotificationsDialog/index.ts create mode 100644 src/components/overlays/NewNotifications/timer.ts delete mode 100644 src/components/overlays/NewNotifications/use-notifications.ts create mode 100644 src/icons/Cross.tsx diff --git a/.storybook/main.js b/.storybook/main.js index 5ff98302..2620bdc3 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,6 +1,14 @@ // @ts-check const webpack = require('webpack'); +const swcConfig = { + jsc: { + parser: { syntax: 'typescript', tsx: true }, + target: 'es2019', + transform: { react: { runtime: 'automatic' } }, + }, +}; + /** @type {import('@storybook/core-common').StorybookConfig} */ const config = { staticDirs: ['../public'], @@ -25,6 +33,13 @@ const config = { '@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions', + { + name: 'storybook-addon-turbo-build', + options: { + managerTranspiler: () => ({ loader: 'swc-loader', options: swcConfig }), + previewTranspiler: () => ({ loader: 'swc-loader', options: swcConfig }), + }, + }, ], webpackFinal: async (config) => { config.plugins.push(new webpack.DefinePlugin({ SC_DISABLE_SPEEDY: true })); diff --git a/package.json b/package.json index 72714ae4..149d80f8 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,7 @@ "@types/react": "^17.0.38", "@types/react-dom": "^17.0.11", "@types/react-test-renderer": "17.0.1", + "@types/react-transition-group": "^4.4.2", "@typescript-eslint/eslint-plugin": "^5.8.1", "@typescript-eslint/parser": "^5.8.1", "bytes": "^3.1.0", @@ -172,7 +173,9 @@ "rimraf": "^3.0.2", "size-limit": "^7.0.5", "styled-components": "5.3.0", - "typescript": "^4.5.4" + "typescript": "^4.5.4", + "storybook-addon-turbo-build": "1.1.0", + "swc-loader": "0.2.3" }, "resolutions": { "es5-ext": "0.10.53", diff --git a/src/_internal/hooks/index.ts b/src/_internal/hooks/index.ts index c011706a..19092c7e 100644 --- a/src/_internal/hooks/index.ts +++ b/src/_internal/hooks/index.ts @@ -1,3 +1,4 @@ export * from './use-deprecation-warning'; export * from './use-event'; export * from './use-sync-ref'; +export * from './use-chained-callback'; diff --git a/src/_internal/hooks/use-chained-callback.ts b/src/_internal/hooks/use-chained-callback.ts new file mode 100644 index 00000000..47a68152 --- /dev/null +++ b/src/_internal/hooks/use-chained-callback.ts @@ -0,0 +1,13 @@ +import { useEvent } from './use-event'; + +export function useChainedCallback( + ...callbacks: (((...args: any) => any) | null | undefined | boolean)[] +) { + return useEvent((...args: any[]) => { + callbacks.forEach((callback) => { + if (typeof callback === 'function') { + callback(...args); + } + }); + }); +} diff --git a/src/components/actions/Button/Button.tsx b/src/components/actions/Button/Button.tsx index 20a3d579..c1b76e49 100644 --- a/src/components/actions/Button/Button.tsx +++ b/src/components/actions/Button/Button.tsx @@ -17,7 +17,7 @@ export interface CubeButtonProps extends CubeActionProps { | 'clear' | 'outline' | 'neutral' - | string; + | (string & {}); size?: 'small' | 'default' | 'large' | (string & {}); } diff --git a/src/components/overlays/NewNotifications/Bar/Bar.tsx b/src/components/overlays/NewNotifications/Bar/Bar.tsx new file mode 100644 index 00000000..8879d796 --- /dev/null +++ b/src/components/overlays/NewNotifications/Bar/Bar.tsx @@ -0,0 +1,60 @@ +import { Key } from 'react'; +import { TransitionGroup } from 'react-transition-group'; +import { Portal } from '../../../portal'; +import { tasty } from '../../../../tasty'; +import { CubeNotifyApiProps } from '../types'; +import { TransitionComponent } from './TransitionComponent'; +import { FloatingNotification } from './FloatingNotification'; + +export type NotificationsBarProps = { + toasts: Map; + onRemoveToast: (id: Key) => void; +}; + +const NotificationsList = tasty({ + styles: { + boxSizing: 'border-box', + position: 'fixed', + bottom: 'env(safe-area-inset-bottom, 0)', + right: 'env(safe-area-inset-right, 0)', + display: 'flex', + flexDirection: 'column-reverse', + width: 'auto 100% 45x', + height: '100vh max', + padding: '2x', + gap: '1x', + zIndex: '100', + overflow: 'hidden', + isolation: 'isolate', + pointerEvents: 'none', + '@supports (-webkit-touch-callout: none)': { + height: '-webkit-fill-available max', + }, + }, +}); + +export function NotificationsBar(props: NotificationsBarProps): JSX.Element { + const { toasts, onRemoveToast } = props; + + return ( + + + + {[...toasts.entries()].reverse().map(([id, notificationProps]) => ( + + + + ))} + + + + ); +} diff --git a/src/components/overlays/NewNotifications/Bar/FloatingNotification.tsx b/src/components/overlays/NewNotifications/Bar/FloatingNotification.tsx new file mode 100644 index 00000000..11440436 --- /dev/null +++ b/src/components/overlays/NewNotifications/Bar/FloatingNotification.tsx @@ -0,0 +1,36 @@ +import { Key, memo } from 'react'; +import { tasty } from '../../../../tasty'; +import { useChainedCallback } from '../../../../_internal'; +import { CubeNotifyApiProps } from '../types'; +import { Notification } from '../Notification'; + +export type FloatingNotificationProps = { + id: Key; + notificationProps: CubeNotifyApiProps; + onRemoveToast: (id: Key) => void; +}; + +const NotificationContainer = tasty({ + styles: { boxShadow: '0 0.5x 2x #shadow', pointerEvents: 'auto' }, +}); + +export const FloatingNotification = memo(function FloatingNotification( + props: FloatingNotificationProps, +): JSX.Element { + const { notificationProps, id, onRemoveToast } = props; + + const chainedOnClose = useChainedCallback(notificationProps.onClose, () => + onRemoveToast(id), + ); + + return ( + + + + ); +}); diff --git a/src/components/overlays/NewNotifications/Bar/TransitionComponent.tsx b/src/components/overlays/NewNotifications/Bar/TransitionComponent.tsx new file mode 100644 index 00000000..5aafd5c6 --- /dev/null +++ b/src/components/overlays/NewNotifications/Bar/TransitionComponent.tsx @@ -0,0 +1,71 @@ +import { PropsWithChildren, useCallback, useRef } from 'react'; +import { CSSTransition } from 'react-transition-group'; +import type { TransitionProps } from 'react-transition-group/Transition'; +import styled from 'styled-components'; + +const CSS_TRANSITION_CLASS_NAME = 'cube-notifications-css-transition'; +const TRANSITION_TIMEOUT = 250; + +const Transition = styled.div` + transition-property: height, opacity, margin-top, margin-bottom; + transition-duration: ${TRANSITION_TIMEOUT}ms; + transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); + + &.${CSS_TRANSITION_CLASS_NAME} { + &-enter { + height: 0; + opacity: 0; + will-change: height, opacity; + } + &-enter-active { + height: var(--__notification-size__); + opacity: 1; + will-change: height, opacity; + } + &-exit { + height: var(--__notification-size__); + opacity: 1; + will-change: height, opacity, margin-top; + } + &-exit-active { + margin-top: -8px; + height: 0; + opacity: 0; + will-change: height, opacity; + } + } +`; + +export type TransitionComponentProps = PropsWithChildren< + Partial +>; + +export function TransitionComponent( + props: TransitionComponentProps, +): JSX.Element { + const { children, ...transitionProps } = props; + const notificationRef = useRef(null); + + const calculateNotificationSize = useCallback(() => { + if (notificationRef.current) { + notificationRef.current.style.setProperty( + '--__notification-size__', + `${notificationRef.current.scrollHeight}px`, + ); + } + }, []); + + return ( + + {children} + + ); +} diff --git a/src/components/overlays/NewNotifications/Bar/index.ts b/src/components/overlays/NewNotifications/Bar/index.ts new file mode 100644 index 00000000..68891d5c --- /dev/null +++ b/src/components/overlays/NewNotifications/Bar/index.ts @@ -0,0 +1 @@ +export * from './Bar'; diff --git a/src/components/overlays/NewNotifications/Notification/Notification.tsx b/src/components/overlays/NewNotifications/Notification/Notification.tsx index 3ba0fdd7..6d5a9d89 100644 --- a/src/components/overlays/NewNotifications/Notification/Notification.tsx +++ b/src/components/overlays/NewNotifications/Notification/Notification.tsx @@ -1,12 +1,16 @@ -import { CubeNotificationProps } from '../types'; +import { ForwardedRef, forwardRef, KeyboardEventHandler, useMemo } from 'react'; +import { useHover } from '@react-aria/interactions'; +import { useFocusRing } from '@react-aria/focus'; +import { mergeProps, useId } from '@react-aria/utils'; import { tasty } from '../../../../tasty'; -import { NotificationAction } from './NotificationAction'; +import { useEvent } from '../../../../_internal'; +import { Timer } from '../timer'; import { NotificationIcon } from './NotificationIcon'; import { NotificationHeader } from './NotificationHeader'; import { NotificationDescription } from './NotificationDescription'; import { NotificationFooter } from './NotificationFooter'; -import { useEvent } from '../../../../_internal'; import { NotificationCloseButton } from './NotificationCloseButton'; +import type { NotificationProps } from './types'; const NotificationContainer = tasty({ styles: { @@ -21,31 +25,85 @@ const NotificationContainer = tasty({ `, gridColumns: 'min-content 1x minmax(0, auto)', fill: '#white', + boxShadow: { + '': '0 0 0 2bw #purple-04.0 inset', + focused: '0 0 0 2bw #purple-04 inset', + }, }, }); -export function Notification(props: CubeNotificationProps) { +export const Notification = forwardRef(function Notification( + props: NotificationProps, + ref: ForwardedRef, +) { const { - onClose = () => {}, + onClose, type = 'attention', actions, header, icon, - isClosable, + isClosable = true, description, + duration = 5_000, + id, + attributes = {}, } = props; - const onCloseEvent = useEvent(onClose); + const labelID = useId(); + const descriptionID = useId(); + + const onCloseEvent = useEvent(() => { + if (isClosable) { + onClose?.(); + } + }); + + const onKeyDown = useEvent>((event) => { + const closeKeys = ['Delete', 'Backspace', 'Escape']; + + if (closeKeys.includes(event.key)) { + onCloseEvent(); + } + }); + + const timer = useMemo(() => { + if (duration === null) { + return null; + } + + return new Timer(onCloseEvent, duration); + }, []); + + const { hoverProps, isHovered } = useHover({ + onHoverStart: timer?.reset, + onHoverEnd: timer?.resume, + }); + + const { isFocusVisible, focusProps } = useFocusRing({ within: true }); return ( - + - {header && } - {description && } + {header && } + {description && ( + + )} {actions && } - {isClosable && } + {isClosable && ( + + )} ); -} - -Notification.Action = NotificationAction; +}); diff --git a/src/components/overlays/NewNotifications/Notification/NotificationCloseButton.tsx b/src/components/overlays/NewNotifications/Notification/NotificationCloseButton.tsx index 12ec3e5a..7104d268 100644 --- a/src/components/overlays/NewNotifications/Notification/NotificationCloseButton.tsx +++ b/src/components/overlays/NewNotifications/Notification/NotificationCloseButton.tsx @@ -1,9 +1,12 @@ -import { CloseOutlined } from '@ant-design/icons'; +import { memo } from 'react'; import { Button } from '../../../actions'; import { tasty } from '../../../../tasty'; +import { Cross } from '../../../../icons'; export type NotificationCloseButtonProps = { onPress: () => void; + isHovered: boolean; + isFocused: boolean; }; const CloseButton = tasty(Button, { @@ -11,26 +14,34 @@ const CloseButton = tasty(Button, { position: 'absolute', right: 0, top: 0, - transform: 'translate(50%, -50%)', + display: 'flex', + placeItems: 'center', + padding: '0.75x', width: '3.5x', height: '3.5x', fill: '#white', - boxShadow: '0px 0.5px 2px #shadow', + shadow: '0 0.5x 2x #shadow', + color: { '': '#dark-02', hovered: '#dark-03', pressed: '#dark-02' }, borderRadius: '50%', - color: '#dark-02', + visibility: { '': 'hidden', show: 'visible' }, + opacity: { '': '0', show: '1' }, + transform: 'translate(50%, -50%)', + transition: 'opacity, visibility 0.2s ease-in-out', }, }); -export function NotificationCloseButton( +export const NotificationCloseButton = memo(function NotificationCloseButton( props: NotificationCloseButtonProps, ): JSX.Element { - const { onPress } = props; + const { onPress, isHovered, isFocused } = props; return ( onPress()} - icon={} + type="neutral" + mods={{ show: isHovered || isFocused }} + onPress={onPress} + icon={} label="Close the notification" /> ); -} +}); diff --git a/src/components/overlays/NewNotifications/Notification/NotificationDescription.tsx b/src/components/overlays/NewNotifications/Notification/NotificationDescription.tsx index 84b699a0..bdff4b4b 100644 --- a/src/components/overlays/NewNotifications/Notification/NotificationDescription.tsx +++ b/src/components/overlays/NewNotifications/Notification/NotificationDescription.tsx @@ -1,19 +1,17 @@ -import { memo } from 'react'; +import { HTMLAttributes, memo } from 'react'; import { tasty } from '../../../../tasty'; import { Paragraph } from '../../../content/Paragraph'; export type NotificationDescriptionProps = { description: string; -}; +} & HTMLAttributes; -const Description = tasty(Paragraph, { - gridArea: 'description', -}); +const Description = tasty(Paragraph, { gridArea: 'description' }); export const NotificationDescription = memo(function NotificationDescription( props: NotificationDescriptionProps, ) { - const { description } = props; + const { description, ...descriptionProps } = props; - return {description}; + return {description}; }); diff --git a/src/components/overlays/NewNotifications/Notification/NotificationHeader.tsx b/src/components/overlays/NewNotifications/Notification/NotificationHeader.tsx index b3c0afd5..5206c84e 100644 --- a/src/components/overlays/NewNotifications/Notification/NotificationHeader.tsx +++ b/src/components/overlays/NewNotifications/Notification/NotificationHeader.tsx @@ -1,20 +1,21 @@ +import { HTMLAttributes, memo } from 'react'; import { Title } from '../../../content/Title'; import { tasty } from '../../../../tasty'; export type NotificationHeaderProps = { header: string; -}; +} & HTMLAttributes; const Header = tasty(Title, { gridArea: 'header', margin: '0.25x 0 0.5x' }); -export function NotificationHeader( +export const NotificationHeader = memo(function NotificationHeader( props: NotificationHeaderProps, ): JSX.Element { - const { header } = props; + const { header, ...headerProps } = props; return ( -
+
{header}
); -} +}); diff --git a/src/components/overlays/NewNotifications/Notification/NotificationIcon.tsx b/src/components/overlays/NewNotifications/Notification/NotificationIcon.tsx index 281b72d5..ee17d893 100644 --- a/src/components/overlays/NewNotifications/Notification/NotificationIcon.tsx +++ b/src/components/overlays/NewNotifications/Notification/NotificationIcon.tsx @@ -1,5 +1,5 @@ import { memo, ReactNode } from 'react'; -import { NotificationIconProps } from '../types'; +import { NotificationIconProps } from './types'; import { tasty } from '../../../../tasty'; import { Danger, Success, Attention } from '../../../../icons'; diff --git a/src/components/overlays/NewNotifications/Notification/index.ts b/src/components/overlays/NewNotifications/Notification/index.ts index ed80171a..73f544d9 100644 --- a/src/components/overlays/NewNotifications/Notification/index.ts +++ b/src/components/overlays/NewNotifications/Notification/index.ts @@ -1 +1,2 @@ export * from './Notification'; +export * from './NotificationAction'; diff --git a/src/components/overlays/NewNotifications/Notification/types.ts b/src/components/overlays/NewNotifications/Notification/types.ts new file mode 100644 index 00000000..aff65c2b --- /dev/null +++ b/src/components/overlays/NewNotifications/Notification/types.ts @@ -0,0 +1,12 @@ +import type { ReactNode, HTMLAttributes } from 'react'; +import type { NotificationType, CubeNotificationProps } from '../types'; + +export type NotificationProps = { + isFocused?: boolean; + attributes?: HTMLAttributes; +} & CubeNotificationProps; + +export type NotificationIconProps = { + type: NotificationType; + icon?: ReactNode; +}; diff --git a/src/components/overlays/NewNotifications/Notifications.stories.tsx b/src/components/overlays/NewNotifications/Notifications.stories.tsx index bd16c9b7..0e69b437 100644 --- a/src/components/overlays/NewNotifications/Notifications.stories.tsx +++ b/src/components/overlays/NewNotifications/Notifications.stories.tsx @@ -1,9 +1,15 @@ import { Meta, Story } from '@storybook/react'; -import { Notification } from './Notification'; +import { Notification, NotificationAction } from './Notification'; import { CubeNotificationProps } from './types'; import { Button } from '../../actions'; -import { useNotifications } from './use-notifications'; +import { useNotifications } from './NotificationsProvider'; import { NotificationsList } from './NotificationsList'; +import { useRef } from 'react'; +import { BellFilled } from '@ant-design/icons'; +import { + NotificationsDialog, + NotificationsDialogTrigger, +} from './NotificationsDialog/NotificationsDialogTrigger'; export default { title: 'Overlays/Notifications', @@ -12,16 +18,20 @@ export default { header: 'Development mode available', description: 'Edit and test your schema without affecting the production.', }, - subcomponents: { NotificationAction: Notification.Action }, + subcomponents: { NotificationAction: NotificationAction }, } as Meta; const ActionTemplate: Story = (args) => { + const pressesRef = useRef(0); const { notify } = useNotifications(); return ( - ); + return ; }; export const DefaultAction = ActionTemplate.bind({}); -export const StandaloneNotification: Story = (args) => { - return ; -}; +export const StandaloneNotification: Story = (args) => ( + +); + +export const AllTypes: Story = () => ( + <> + + Activate + , + + Never show this again + , + ]} + /> + + Restart} + /> + +); export const ClosableNotification = StandaloneNotification.bind({}); ClosableNotification.args = { isClosable: true }; @@ -85,12 +98,45 @@ export const NotificationsInModal: Story = (args) => {