diff --git a/packages/notifications-panel/CHANGELOG.md b/packages/notifications-panel/CHANGELOG.md new file mode 100644 index 0000000000..8d5710bc45 --- /dev/null +++ b/packages/notifications-panel/CHANGELOG.md @@ -0,0 +1,6 @@ +### Features + +* this component leverages @hig/notifications-flyout@3.2.0 ([9b8069e](https://github.com/DynamoDS/hig/pull/1/commits/9b8069ec26b66cfad929b88edd20eb0c1a145676)) +* Addition of the 'Mark all as read' feature + +# [@hig/notifications-panel-v0.1.0](https://github.com/DynamoDS/hig/pull/1) (2022-08-04) \ No newline at end of file diff --git a/packages/notifications-panel/README.md b/packages/notifications-panel/README.md new file mode 100644 index 0000000000..02ddf8e947 --- /dev/null +++ b/packages/notifications-panel/README.md @@ -0,0 +1,50 @@ +# Notifications Panel + +The notifications panel provides information and warnings that products may recover from without user involvement. It is meant to be displayed in a flyout as a list of notifications. + +## Getting started + +```bash +yarn add @hig/notifications-panel @hig/theme-context @hig/theme-data +``` + +## Import the component + +```js +import NotificationsPanel, { Notification } from "@hig/notifications-panel"; +``` + +## Basic usage + +```jsx + + +

Your subscription expires May 5

+
+
+``` + +## Advanced usage + +```jsx +import NotificationsPanel, { anchorPoints } from "@hig/notifications-panel"; +import Timestamp from "@hig/timestamp"; + +, + content:

Something happened

+ }, + { + content: "Hello world" + } + ]} +/> +``` diff --git a/packages/notifications-panel/__mocks__/@hig/progress-ring.js b/packages/notifications-panel/__mocks__/@hig/progress-ring.js new file mode 100644 index 0000000000..e68f41e4e4 --- /dev/null +++ b/packages/notifications-panel/__mocks__/@hig/progress-ring.js @@ -0,0 +1,8 @@ +/** @todo Remove when `@hig/progress-ring` is migrated from vanilla */ +import React from "react"; + +function ProgressRingMock(props) { + return
; +} + +module.exports = jest.fn(ProgressRingMock); diff --git a/packages/notifications-panel/package.json b/packages/notifications-panel/package.json new file mode 100644 index 0000000000..1bd88dfbec --- /dev/null +++ b/packages/notifications-panel/package.json @@ -0,0 +1,71 @@ +{ + "name": "@hig/notifications-panel", + "version": "0.0.1", + "description": "HIG NotificationsPanel", + "author": "Autodesk Inc.", + "license": "Apache-2.0", + "homepage": "https://hig.autodesk.com", + "repository": { + "type": "git", + "url": "https://github.com/DynamoDS/hig.git" + }, + "publishConfig": { + "access": "public" + }, + "main": "build/index.js", + "module": "build/index.es.js", + "files": [ + "build/*" + ], + "dependencies": { + "@hig/behaviors": "^2.1.0", + "@hig/icon-button": "^3.1.0", + "@hig/icons": "^4.1.0", + "@hig/progress-ring": "^2.2.0", + "@hig/rich-text": "^2.1.0", + "@hig/typography": "^2.1.0", + "@hig/utils": "^0.4.1", + "emotion": "^10.0.0", + "prop-types": "^15.7.1", + "react-transition-group": "^4.4.2" + }, + "devDependencies": { + "@hig/avatar": "^2.1.0", + "@hig/babel-preset": "^0.1.1", + "@hig/eslint-config": "^0.1.0", + "@hig/jest-preset": "^0.1.0", + "@hig/scripts": "^0.1.2", + "@hig/semantic-release-config": "^0.1.0", + "@hig/text-link": "^2.1.0" + }, + "peerDependencies": { + "@hig/theme-context": "^4.2.0", + "@hig/theme-data": "^3.0.0", + "react": "^17.0.0" + }, + "scripts": { + "build": "hig-scripts-build", + "lint": "hig-scripts-lint", + "test": "hig-scripts-test", + "release": "hig-scripts-release", + "publish:npm": "rm -rf dist && mkdir dist && babel src/component -d dist --copy-files" + }, + "eslintConfig": { + "extends": "@hig" + }, + "jest": { + "preset": "@hig/jest-preset" + }, + "release": { + "extends": "@hig/semantic-release-config" + }, + "babel": { + "env": { + "test": { + "presets": [ + "@hig/babel-preset/test" + ] + } + } + } +} diff --git a/packages/notifications-panel/src/Notification.js b/packages/notifications-panel/src/Notification.js new file mode 100644 index 0000000000..79b721ddc6 --- /dev/null +++ b/packages/notifications-panel/src/Notification.js @@ -0,0 +1,127 @@ +import PropTypes from "prop-types"; +import React from "react"; +import { ControlBehavior } from "@hig/behaviors"; + +import NotificationBehavior from "./behaviors/NotificationBehavior"; +import NotificationPresenter from "./presenters/NotificationPresenter"; + +const Notification = (props) => { + /** + * @param {NotificationShape} shape + * @returns {import("react").ReactElement} + */ + const renderChildren = () => { + const { children, hideFlyout, onDismiss: dismiss } = props; + + if (typeof children !== "function") { + return children; + } + + return children({ dismiss, hideFlyout }); + }; + + const { + dismissButtonTitle, + featured, + image, + onDismiss, + onNotificationClick, + onMouseEnter, + onMouseLeave, + // Featured notifications show the dismiss button by default + showDismissButton = featured, + stylesheet, + timestamp, + type, + unread, + ...otherProps + } = props; + const { className } = otherProps; + + return ( + + {({ handleDismissButtonClick, height, innerRef, transitionStatus }) => ( + + {({ + hasHover, + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + }) => ( + + {renderChildren()} + + )} + + )} + + ); +}; + +Notification.displayName = "Notification"; + +Notification.propTypes = { + /** Notification content */ + children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), + /** Title HTML attribute for the dismiss button */ + dismissButtonTitle: PropTypes.string, + /** Determines whether the notification is featured */ + featured: PropTypes.bool, + /** An action provided hide the flyout. This is provided to the `children` render prop */ + hideFlyout: PropTypes.func, + /** An image to display such as an avatar or and icon */ + image: PropTypes.node, + /** A callback called when user dismisses a featured notification */ + onDismiss: PropTypes.func, + /** A callback when the user clicks anywhere within the notification */ + onNotificationClick: PropTypes.func, + /** + * Triggers when the user's mouse is over the notification + */ + onMouseEnter: PropTypes.func, + /** + * Triggers when the user's mouse is no longer over the notification + */ + onMouseLeave: PropTypes.func, + /** Determines whether the dismiss button is shown */ + showDismissButton: PropTypes.bool, + /** Function to modify the component's styles */ + stylesheet: PropTypes.func, + /** Timestamp component */ + timestamp: PropTypes.node, + /** Determines notification variant */ + type: PropTypes.string, + /** Determines whether notification has not been read */ + unread: PropTypes.bool, +}; + +Notification.defaultProps = { + /** + * This is an action that's provided to the consumer, + * as a result a value must always be available. + */ + hideFlyout: () => {}, + onNotificationClick: () => {}, +}; + +export default Notification; diff --git a/packages/notifications-panel/src/NotificationsPanel.js b/packages/notifications-panel/src/NotificationsPanel.js new file mode 100644 index 0000000000..58007c3d8f --- /dev/null +++ b/packages/notifications-panel/src/NotificationsPanel.js @@ -0,0 +1,152 @@ +import React from "react"; +import Panel from "./Panel"; +import Notification from "./Notification"; +import EmptyStatePresenter from "./presenters/EmptyStatePresenter"; +import PropTypes from "prop-types"; +import NotificationFlyoutBehavior from "./behaviors/NotificationFlyoutBehavior"; +import { combineEventHandlers } from "@hig/utils"; + +/** @typedef {import("./behaviors/parseNotifications").ParsedNotification} ParsedNotification */ + +/** + * @param {Object} payload + * @returns {function(ParsedNotification): JSX} + */ +function CreateNotificationRenderer({ dismissNotification }) { + /* eslint-disable-next-line react/prop-types */ + return function renderNotification(notification) { + const { + content, + dismissButtonTitle, + featured, + id, + image, + key, + onDismiss, + onNotificationClick, + showDismissButton, + stylesheet, + timestamp, + type, + unread, + ...otherProps + } = notification; + const { className } = otherProps; + + const handleDismiss = combineEventHandlers(onDismiss, () => + dismissNotification(id) + ); + + return ( + + {content} + + ); + }; +} + +export default function NotificationsPanel(props) { + + const { + alterCoordinates, + anchorPoint, + children, + emptyMessage, + heading, + indicatorTitle, + loading, + onClick, + onClickOutside, + onScroll, + open, + markAllAsReadTitle, + onClickMarkAllAsRead, + notifications: notificationsInput = children, + unreadCount: controlledUnreadCount, + stylesheet, + ...otherProps + } = props; + const { className } = otherProps; + + return ( + + {({ + dismissNotification, + handleClose, + notifications, + showUnreadCount, + unreadCount + }) => ( + { }} + markAllAsReadTitle={markAllAsReadTitle} + onClickMarkAllAsRead={onClickMarkAllAsRead} + heading={heading}> + {notifications.length == 0 ? ( + + ) : ( + notifications.map( + CreateNotificationRenderer({ dismissNotification }) + ) + )} + + )} + + ) +} + +NotificationsPanel.propTypes = { + /** Manipulate flyout coordinates before each render */ + alterCoordinates: PropTypes.func, + /** Rendered notifications. It can contain one or more components. */ + children: PropTypes.node, + /** The message displayed when there are no notifications */ + emptyMessage: PropTypes.string, + /** Flyout panel heading */ + heading: PropTypes.string, + /** Indicator button title */ + indicatorTitle: PropTypes.string, + /** Determines whether the loading animation is shown */ + loading: PropTypes.bool, + /** + * Rendered notifications. + * It takes precedent over `children`, and accepts an array + * containing any combination of components + * and Notification models + */ + notifications: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.node, PropTypes.object]) + ), + /** Function called when the flyout is opened */ + onClick: PropTypes.func, + /** Function called when the flyout is open, and a click event occurs outside the flyout */ + onClickOutside: PropTypes.func, + /** Function called when the flyout panel is scrolled */ + onScroll: PropTypes.func, + /** When provided, it overrides the flyout's open state */ + open: PropTypes.bool, + /** Function to modify the component's styles */ + stylesheet: PropTypes.func, + /** When provided, it overrides the derived unread notification count */ + unreadCount: PropTypes.number, + /** The title related to the 'Mark all as read' button */ + markAllAsReadTitle: PropTypes.string, + /** Function called when the 'Mark all as read' button */ + onClickMarkAllAsRead: PropTypes.func +}; \ No newline at end of file diff --git a/packages/notifications-panel/src/Panel.js b/packages/notifications-panel/src/Panel.js new file mode 100644 index 0000000000..c1d5fb91f6 --- /dev/null +++ b/packages/notifications-panel/src/Panel.js @@ -0,0 +1,51 @@ +import React from "react"; +import PropTypes from "prop-types"; + +import PanelBehavior from "./behaviors/PanelBehavior"; +import PanelPresenter from "./presenters/PanelPresenter"; + +export default function Panel(props) { + const { + children, + heading, + innerRef, + loading, + onScroll, + transitionStatus, + stylesheet, + markAllAsReadTitle, + onClickMarkAllAsRead + } = props; + + return ( + + {({ listMaxHeight, loadingTransitionState, refListWrapper }) => ( + + {children} + + )} + + ); +} + +Panel.propTypes = { + children: PropTypes.node, + markAllAsReadTitle: PropTypes.string, + heading: PropTypes.string, + innerRef: PropTypes.func.isRequired, + loading: PropTypes.bool, + onScroll: PropTypes.func, + onClickMarkAllAsRead: PropTypes.func, + stylesheet: PropTypes.func, + transitionStatus: PropTypes.string, +}; diff --git a/packages/notifications-panel/src/__stories__/NotificationsPanel.new-stories.js b/packages/notifications-panel/src/__stories__/NotificationsPanel.new-stories.js new file mode 100644 index 0000000000..6bdd8573d0 --- /dev/null +++ b/packages/notifications-panel/src/__stories__/NotificationsPanel.new-stories.js @@ -0,0 +1,24 @@ +import React from "react"; +import NotificationsPanel from "../NotificationsPanel"; + +export default { + title: "NotificationsPanel", + component: NotificationsPanel +} + +const clickButton = () => { console.log("button clicked") } + +export const NotsPanel = () => , + content:

Something happened

+ }, + { + id: 2, + featured: true, + content: "Hello world" + } +]}/> \ No newline at end of file diff --git a/packages/notifications-panel/src/behaviors/NotificationBehavior.js b/packages/notifications-panel/src/behaviors/NotificationBehavior.js new file mode 100644 index 0000000000..9fda23d90a --- /dev/null +++ b/packages/notifications-panel/src/behaviors/NotificationBehavior.js @@ -0,0 +1,93 @@ +import React, { useState, useRef } from "react"; +import Transition from "react-transition-group/Transition"; +import PropTypes from "prop-types"; + +const TRANSITION_DURATION = 300; + +/** + * @typedef {Object} Payload + * @property {string} transitionStatus + * @property {function(MouseEvent): void} handleDismissButtonClick + */ + +const NotificationBehavior = (props) => { + const [height, setHeight] = useState(""); + const [isVisible, setIsVisible] = useState(true); + const containerRef = useRef(null); + + /** + * @param {HTMLDivElement} containerRef + */ + const refContainer = (containerRefParams) => { + containerRef.current = containerRefParams; + }; + + const handleExit = () => { + const { onDismiss } = props; + + if (onDismiss) onDismiss(); + }; + + /** + * Sets the current height of the notification + * so that the height transition can occur + */ + const prepareHideTransition = () => + new Promise((resolve) => { + setHeight(`${containerRef.current.clientHeight}px`); + window.requestAnimationFrame(resolve); + }); + + /** + * Begins the hide transition by setting the `isVisible` prop + * The height is then controlled via CSS in the `NotificationPresenter` + * + * This method calls `requestAnimationFrame` again to ensure that a repaint occurs. + * @see https://stackoverflow.com/a/42302185 + */ + const startHideTransition = () => { + window.requestAnimationFrame(() => { + setHeight(""); + setIsVisible(false); + }); + }; + + const hide = () => { + prepareHideTransition().then(() => startHideTransition()); + }; + + const handleDismissButtonClick = () => { + hide(); + }; + + const innerRef = refContainer; + const { children } = props; + + return ( + + {(transitionStatus) => + children({ + handleDismissButtonClick, + height, + innerRef, + transitionStatus, + }) + } + + ); +}; + +NotificationBehavior.displayName = "NotificationBehavior"; + +NotificationBehavior.propTypes = { + /** Notification content */ + children: PropTypes.func.isRequired, + /** A callback called when user dismisses a featured notification */ + onDismiss: PropTypes.func, +}; + +export default NotificationBehavior; diff --git a/packages/notifications-panel/src/behaviors/NotificationFlyoutBehavior.js b/packages/notifications-panel/src/behaviors/NotificationFlyoutBehavior.js new file mode 100644 index 0000000000..c1574deaa1 --- /dev/null +++ b/packages/notifications-panel/src/behaviors/NotificationFlyoutBehavior.js @@ -0,0 +1,156 @@ +/* eslint-disable react/no-unused-prop-types */ + +import { Component } from "react"; +import PropTypes from "prop-types"; + +import parseNotifications from "./parseNotifications"; + +/** @typedef {import("./parseNotifications").Input} NotificationsInput */ +/** @typedef {import("./parseNotifications").ParsedNotification} ParsedNotification */ +/** @typedef {import("./NotificationBehavior").Payload} NotificationBehaviorPayload */ + +/** + * @typedef {Object} Payload + * @property {Function} dismissNotification + * @property {ParsedNotification[]} notifications + * @property {boolean} showUnreadCount + * @property {number} unreadCount + */ + +/** + * @typedef {Object} Props + * @property {function(Payload): import("react").ReactElement} [children] + * @property {NotificationsInput} [notifications] + * @property {number} [unreadCount] + */ + +/** + * @typedef {Object} State + * @property {string[]} dismissedNotifications An array of notification IDs that have been dismissed + * @property {ParsedNotification[]} notifications + * @property {string[]} readNotifications An array of notification IDs that have been read + */ + +export default class NotificationFlyoutBehavior extends Component { + /** @type {Props} */ + props; + + // eslint-disable-next-line react/static-property-placement + static propTypes = { + /** Render prop */ + children: PropTypes.func.isRequired, + /** Rendered notifications */ + notifications: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.node, PropTypes.shape({})]) + ), + ]), + /** When provided, it overrides the derived unread notification count */ + unreadCount: PropTypes.number, + }; + + /** @type {State} */ + // eslint-disable-next-line react/state-in-constructor + state = { + dismissedNotifications: [], + notifications: [], + readNotifications: [], + }; + + /** + * @param {Props} nextProps + * @returns {State | null} + */ + static getDerivedStateFromProps(nextProps) { + return { + notifications: parseNotifications(nextProps.notifications), + }; + } + + /** + * @returns {ParsedNotification[]} + */ + getNotifications() { + const { dismissedNotifications, notifications, readNotifications } = + this.state; + + const updateReadStatus = ({ id, unread, ...otherProps }) => ({ + id, + unread: unread && !readNotifications.includes(id), + ...otherProps, + }); + const isNotDismissed = ({ id }) => !dismissedNotifications.includes(id); + + return notifications.map(updateReadStatus).filter(isNotDismissed); + } + + /** @returns {number} */ + getUnreadCount() { + const { unreadCount: controlledUnreadCount } = this.props; + + return controlledUnreadCount !== undefined + ? controlledUnreadCount + : this.deriveUnreadCount(); + } + + /** + * Action to dismiss a notification + * @param {string} id + */ + dismissNotification = (id) => { + this.setState({ + // eslint-disable-next-line react/no-access-state-in-setstate + dismissedNotifications: this.state.dismissedNotifications.concat(id), + }); + }; + + /** + * Handler for when the flyout opens + */ + handleClose = () => { + this.markAllNotificationsRead(); + }; + + /** + * @param {ParsedNotification[]} notifications + */ + deriveUnreadCount() { + return this.getNotifications().reduce( + (count, { unread }) => (unread ? count + 1 : count), + 0 + ); + } + + markAllNotificationsRead() { + const notifications = this.getNotifications(); + const { readNotifications } = this.state; + const nextRead = notifications.reduce((result, notification) => { + const { id } = notification; + + if (!result.includes(id)) result.push(id); + + return result; + }, readNotifications.slice()); + + this.setState({ readNotifications: nextRead }); + } + + /** + * @returns {import("react").ReactElement} + */ + render() { + const { dismissNotification, handleClose } = this; + const notifications = this.getNotifications(); + const unreadCount = this.getUnreadCount(); + const showUnreadCount = unreadCount > 0; + + return this.props.children({ + dismissNotification, + handleClose, + notifications, + showUnreadCount, + unreadCount, + }); + } +} diff --git a/packages/notifications-panel/src/behaviors/PanelBehavior.js b/packages/notifications-panel/src/behaviors/PanelBehavior.js new file mode 100644 index 0000000000..049e8c6fc6 --- /dev/null +++ b/packages/notifications-panel/src/behaviors/PanelBehavior.js @@ -0,0 +1,97 @@ +import React, { useState, useEffect, useRef } from "react"; +import PropTypes from "prop-types"; +import Transition from "react-transition-group/Transition"; +import { transitionStatuses, AVAILABLE_TRANSITION_STATUSES } from "@hig/flyout"; + +/** 50px per the design spec plus 30px for the height of the title */ +const BOTTOM_SPACING = 80; +const TRANSITION_DURATION = 300; + +/** + * @param {HTMLDivElement} listWrapper + * @returns {string} + */ +function calculateListMaxHeight(listWrapper) { + if (!listWrapper) return ""; + + const { top: listWrapperTop } = listWrapper.getBoundingClientRect(); + + // Distance between the top of the list wrapper and the spacing at the bottom of the screen + const height = window.innerHeight - BOTTOM_SPACING - listWrapperTop; + + return `${height}px`; +} + +const PanelBehavior = (props) => { + const [listMaxHeight, setListMaxHeight] = useState(""); + const listWrapperRef = useRef(null); + + const updateMaxHeight = () => { + setListMaxHeight(calculateListMaxHeight(listWrapperRef.current)); + }; + + const handleResize = () => { + updateMaxHeight(); + }; + + const bindResize = () => { + window.addEventListener("resize", handleResize); + }; + + const unbindResize = () => { + window.removeEventListener("resize", handleResize); + }; + + /** + * @param {HTMLDivElement} listWrapperRef + */ + const refListWrapper = (value) => { + listWrapperRef.current = value; + }; + + const { children, loading } = props; + + useEffect(() => { + bindResize(); + updateMaxHeight(); + + return () => { + unbindResize(); + }; + }, []); + + useEffect(() => { + if ( + props.transitionStatus === transitionStatuses.HIDDEN || + props.transitionStatus === transitionStatuses.EXITED + ) { + window.requestAnimationFrame(() => { + updateMaxHeight(); + }); + } + }, [props]); + + return ( + + {(loadingTransitionState) => + children({ + loadingTransitionState, + listMaxHeight, + refListWrapper, + }) + } + + ); +}; + +PanelBehavior.propTypes = { + children: PropTypes.func.isRequired, + loading: PropTypes.bool, + transitionStatus: PropTypes.oneOf(AVAILABLE_TRANSITION_STATUSES), +}; + +PanelBehavior.defaultProps = { + loading: false, +}; + +export default PanelBehavior; diff --git a/packages/notifications-panel/src/behaviors/__snapshots__/teste.NotificationFlyoutBehavior.test.js.snap b/packages/notifications-panel/src/behaviors/__snapshots__/teste.NotificationFlyoutBehavior.test.js.snap new file mode 100644 index 0000000000..0117e6f7d7 --- /dev/null +++ b/packages/notifications-panel/src/behaviors/__snapshots__/teste.NotificationFlyoutBehavior.test.js.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`notification-flyout/behaviors/NotificationFlyoutBehavior render prop payload normalizes notifications 1`] = ` +Array [ + Object { + "content": "Featured", + "featured": true, + "hideFlyout": [Function], + "id": "1", + "key": "0", + "onNotificationClick": [Function], + "unread": true, + }, + Object { + "content": "Foo", + "hideFlyout": [Function], + "id": "2", + "key": "1", + "onNotificationClick": [Function], + "unread": true, + }, + Object { + "content": "Bar", + "hideFlyout": [Function], + "id": "3", + "key": "2", + "onNotificationClick": [Function], + "unread": true, + }, +] +`; + +exports[`notification-flyout/behaviors/NotificationFlyoutBehavior render prop payload provides a payload to the \`children\` render prop 1`] = ` +Array [ + Object { + "content": "Featured", + "featured": true, + "hideFlyout": [Function], + "id": "1", + "key": "0", + "onNotificationClick": [Function], + "unread": true, + }, + Object { + "content": "Foo", + "hideFlyout": [Function], + "id": "2", + "key": "1", + "onNotificationClick": [Function], + "unread": true, + }, + Object { + "content": "Bar", + "hideFlyout": [Function], + "id": "3", + "key": "2", + "onNotificationClick": [Function], + "unread": true, + }, +] +`; diff --git a/packages/notifications-panel/src/behaviors/parseNotifications.js b/packages/notifications-panel/src/behaviors/parseNotifications.js new file mode 100644 index 0000000000..27ca55f292 --- /dev/null +++ b/packages/notifications-panel/src/behaviors/parseNotifications.js @@ -0,0 +1,72 @@ +import { Children } from "react"; +import Notification from "../Notification"; + +/** @typedef {null|undefined|NotificationElements} Input */ + +/** + * @typedef {Object} ParsedNotification + * @property {string} id + * @property {string} key + * @property {NotificationContent} [content] + * @property {boolean} [featured] + * @property {function(): void} [onDismiss] + * @property {boolean} [showDismissButton] + * @property {import("react").ReactElement} [timestamp] + * @property {string} [type] + * @property {boolean} unread + */ + +/** + * Converts the given notifications input into an array + * @param {Input} input + * @returns {Object[]} + */ +function normalizeInput(input) { + if (input == null) { + return []; + } + + if (Array.isArray(input)) { + return input; + } + + const element = Children.only(input); + + if (element.type === Notification) { + return [element]; + } + + throw new Error("Invalid notifications value."); +} + +/** + * @param {Object} value + * @param {number} index + * @returns {ParsedNotification} + */ +function parseNotification(value, index) { + const { key = `notification-${index}`, ...otherValues } = value; + const { + children, + content = children, + id = index.toString(), + unread = true, + ...props + } = otherValues.props || otherValues; + + return { + id, + key, + content, + unread, + ...props, + }; +} + +/** + * @param {Input} input + * @returns {ParsedNotification[]} + */ +export default function parseNotifications(input) { + return normalizeInput(input).map(parseNotification); +} diff --git a/packages/notifications-panel/src/index.js b/packages/notifications-panel/src/index.js new file mode 100644 index 0000000000..00c229b0b2 --- /dev/null +++ b/packages/notifications-panel/src/index.js @@ -0,0 +1,9 @@ +import Image from "./presenters/ImagePresenter"; +import NotificationsPanel from "./NotificationsPanel"; +import Notification from "./Notification"; + +NotificationsPanel.Image = Image; +NotificationsPanel.Notification = Notification; + +export { NotificationsPanel as default, Image, Notification }; +export { types, AVAILABLE_TYPES } from "./types"; diff --git a/packages/notifications-panel/src/presenters/Bell.svg b/packages/notifications-panel/src/presenters/Bell.svg new file mode 100644 index 0000000000..57d8c55467 --- /dev/null +++ b/packages/notifications-panel/src/presenters/Bell.svg @@ -0,0 +1,40 @@ + + + + empty-illustration + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/notifications-panel/src/presenters/DismissButtonPresenter.js b/packages/notifications-panel/src/presenters/DismissButtonPresenter.js new file mode 100644 index 0000000000..547257267b --- /dev/null +++ b/packages/notifications-panel/src/presenters/DismissButtonPresenter.js @@ -0,0 +1,39 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { css, cx } from "emotion"; +import IconButton from "@hig/icon-button"; +import { CloseSUI } from "@hig/icons"; +import { createCustomClassNames } from "@hig/utils"; + +import stylesheet from "./stylesheet"; + +export default function DismissButtonPresenter({ + hasHover, + onClick, + stylesheet: customStylesheet, + title, + ...otherProps +}) { + const { className } = otherProps; + const dismissButtonClassName = createCustomClassNames( + className, + "dismiss-button" + ); + const styles = stylesheet({ hasHover, stylesheet: customStylesheet }, {}); + return ( +
+ } title={title} /> +
+ ); +} + +DismissButtonPresenter.defaultProps = { + title: "Dismiss featured notification", +}; + +DismissButtonPresenter.propTypes = { + hasHover: PropTypes.bool, + onClick: PropTypes.func, + stylesheet: PropTypes.func, + title: PropTypes.string, +}; diff --git a/packages/notifications-panel/src/presenters/DismissButtonPresenter.test.js b/packages/notifications-panel/src/presenters/DismissButtonPresenter.test.js new file mode 100644 index 0000000000..99386fe036 --- /dev/null +++ b/packages/notifications-panel/src/presenters/DismissButtonPresenter.test.js @@ -0,0 +1,19 @@ +import { takeSnapshotsOf } from "@hig/jest-preset/helpers"; + +import DismissButtonPresenter from "./DismissButtonPresenter"; + +describe("notifications-flyout/presenters/DismissButtonPresenter", () => { + takeSnapshotsOf(DismissButtonPresenter, [ + { + desc: "renders without props", + props: {}, + }, + { + desc: "renders with all props", + props: { + onClick: () => {}, + title: "HELLO", + }, + }, + ]); +}); diff --git a/packages/notifications-panel/src/presenters/EmptyStatePresenter.js b/packages/notifications-panel/src/presenters/EmptyStatePresenter.js new file mode 100644 index 0000000000..b43a22800f --- /dev/null +++ b/packages/notifications-panel/src/presenters/EmptyStatePresenter.js @@ -0,0 +1,29 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { css } from "emotion"; +import Typography from "@hig/typography"; + +import Bell from "./Bell.svg"; +import stylesheet from "./stylesheet"; + +export default function EmptyStatePresenter({ + message, + stylesheet: customStylesheet, +}) { + const styles = stylesheet({ stylesheet: customStylesheet }, {}); + return ( +
+ + {message} +
+ ); +} + +EmptyStatePresenter.defaultProps = { + message: "You currently have no notifications", +}; + +EmptyStatePresenter.propTypes = { + message: PropTypes.string, + stylesheet: PropTypes.func, +}; diff --git a/packages/notifications-panel/src/presenters/EmptyStatePresenter.test.js b/packages/notifications-panel/src/presenters/EmptyStatePresenter.test.js new file mode 100644 index 0000000000..6be6551a90 --- /dev/null +++ b/packages/notifications-panel/src/presenters/EmptyStatePresenter.test.js @@ -0,0 +1,18 @@ +import { takeSnapshotsOf } from "@hig/jest-preset/helpers"; + +import EmptyStatePresenter from "./EmptyStatePresenter"; + +describe("notifications-flyout/presenters/EmptyStatePresenter", () => { + takeSnapshotsOf(EmptyStatePresenter, [ + { + desc: "renders without props", + props: {}, + }, + { + desc: "renders with all props", + props: { + message: "hello world", + }, + }, + ]); +}); diff --git a/packages/notifications-panel/src/presenters/ImagePresenter.js b/packages/notifications-panel/src/presenters/ImagePresenter.js new file mode 100644 index 0000000000..e51a64662a --- /dev/null +++ b/packages/notifications-panel/src/presenters/ImagePresenter.js @@ -0,0 +1,9 @@ +/* eslint-disable jsx-a11y/alt-text */ +import React from "react"; +import { css } from "emotion"; + +import stylesheet from "./stylesheet"; + +export default function ImagePresenter(props) { + return ; +} diff --git a/packages/notifications-panel/src/presenters/ImagePresenter.test.js b/packages/notifications-panel/src/presenters/ImagePresenter.test.js new file mode 100644 index 0000000000..37aa034ce7 --- /dev/null +++ b/packages/notifications-panel/src/presenters/ImagePresenter.test.js @@ -0,0 +1,16 @@ +import { takeSnapshotsOf } from "@hig/jest-preset/helpers"; + +import ImagePresenter from "./ImagePresenter"; + +describe("notifications-flyout/presenters/ImagePresenter", () => { + takeSnapshotsOf(ImagePresenter, [ + { + desc: "renders with img props", + props: { + alt: "hello", + src: "//example.com/random.png", + "data-something": "anything", + }, + }, + ]); +}); diff --git a/packages/notifications-panel/src/presenters/IndicatorPresenter.js b/packages/notifications-panel/src/presenters/IndicatorPresenter.js new file mode 100644 index 0000000000..8df977d9a9 --- /dev/null +++ b/packages/notifications-panel/src/presenters/IndicatorPresenter.js @@ -0,0 +1,58 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { css, cx } from "emotion"; +import IconButton from "@hig/icon-button"; +import { Notification16, Notification24 } from "@hig/icons"; +import ThemeContext from "@hig/theme-context"; +import { createCustomClassNames } from "@hig/utils"; + +import stylesheet from "./stylesheet"; + +export default function IndicatorPresenter(props) { + const { count, onClick, title, ...otherProps } = props; + const { className } = otherProps; + const indicatorClassName = createCustomClassNames(className, "indicator"); + const indicatorCountClassName = createCustomClassNames( + className, + "indicator-count" + ); + + return ( + + {({ resolvedRoles, metadata }) => { + const styles = stylesheet(props, resolvedRoles); + const NotificationIcon = + metadata.densityId === "high-density" + ? Notification16 + : Notification24; + return ( +
+ } + title={title} + /> +
+ {count} +
+
+ ); + }} +
+ ); +} + +IndicatorPresenter.defaultProps = { + title: "View notifications", +}; + +IndicatorPresenter.propTypes = { + count: PropTypes.number, + onClick: PropTypes.func, + title: PropTypes.string, +}; diff --git a/packages/notifications-panel/src/presenters/IndicatorPresenter.test.js b/packages/notifications-panel/src/presenters/IndicatorPresenter.test.js new file mode 100644 index 0000000000..185a6078a0 --- /dev/null +++ b/packages/notifications-panel/src/presenters/IndicatorPresenter.test.js @@ -0,0 +1,21 @@ +import { takeSnapshotsOf } from "@hig/jest-preset/helpers"; + +import IndicatorPresenter from "./IndicatorPresenter"; + +describe("notifications-flyout/presenters/IndicatorPresenter", () => { + takeSnapshotsOf(IndicatorPresenter, [ + { + desc: "renders without props", + props: {}, + }, + { + desc: "renders with all props", + props: { + count: 3, + onClick: () => {}, + showCount: true, + title: "hello world", + }, + }, + ]); +}); diff --git a/packages/notifications-panel/src/presenters/NotificationPresenter.js b/packages/notifications-panel/src/presenters/NotificationPresenter.js new file mode 100644 index 0000000000..e5494f9c6c --- /dev/null +++ b/packages/notifications-panel/src/presenters/NotificationPresenter.js @@ -0,0 +1,114 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { css, cx } from "emotion"; +import RichText from "@hig/rich-text"; +import ThemeContext from "@hig/theme-context"; +import { createCustomClassNames } from "@hig/utils"; + +import DismissButtonPresenter from "./DismissButtonPresenter"; +import stylesheet from "./stylesheet"; + +export default function NotificationPresenter(props) { + const { + children, + dismissButtonTitle, + hasHover, + height, + image, + innerRef, + onDismissButtonClick, + onNotificationClick, + onMouseEnter, + onMouseLeave, + showDismissButton, + stylesheet: customStylesheet, + timestamp, + ...otherProps + } = props; + const { className } = otherProps; + const notificationContentClassName = createCustomClassNames( + className, + "notification-content" + ); + const notificationContentImageClassName = createCustomClassNames( + className, + "notification-content-image" + ); + const notificationContentTextClassName = createCustomClassNames( + className, + "notification-content-text" + ); + + return ( + /* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */ + + {({ resolvedRoles }) => { + const styles = stylesheet(props, resolvedRoles); + return ( +
+
+ {image ? ( +
+ {image} +
+ ) : null} +
+ {children} + {timestamp} + {showDismissButton ? ( + + ) : null} +
+
+
+ ); + }} +
+ /* eslint-enable jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */ + ); +} + +NotificationPresenter.propTypes = { + children: PropTypes.node, + dismissButtonTitle: PropTypes.string, + hasHover: PropTypes.bool, + height: PropTypes.string, + image: PropTypes.node, + innerRef: PropTypes.func, + onDismissButtonClick: PropTypes.func, + onNotificationClick: PropTypes.func, + onMouseEnter: PropTypes.func, + onMouseLeave: PropTypes.func, + showDismissButton: PropTypes.bool, + stylesheet: PropTypes.func, + timestamp: PropTypes.node, +}; diff --git a/packages/notifications-panel/src/presenters/NotificationPresenter.test.js b/packages/notifications-panel/src/presenters/NotificationPresenter.test.js new file mode 100644 index 0000000000..97c07facb6 --- /dev/null +++ b/packages/notifications-panel/src/presenters/NotificationPresenter.test.js @@ -0,0 +1,30 @@ +import { ENTERED } from "react-transition-group/Transition"; +import { takeSnapshotsOf } from "@hig/jest-preset/helpers"; + +import NotificationPresenter from "./NotificationPresenter"; +import { types } from "../types"; + +describe("notifications-flyout/presenters/NotificationPresenter", () => { + takeSnapshotsOf(NotificationPresenter, [ + { + desc: "renders without props", + props: {}, + }, + { + desc: "renders with all props", + props: { + children: "foobar", + dismissButtonTitle: "hello world", + featured: true, + height: "3000px", + innerRef: () => {}, + onDismissButtonClick: () => {}, + showDismissButton: true, + timestamp: "2018-08-17", + transitionStatus: ENTERED, + type: types.SUCCESS, + unread: true, + }, + }, + ]); +}); diff --git a/packages/notifications-panel/src/presenters/PanelPresenter.js b/packages/notifications-panel/src/presenters/PanelPresenter.js new file mode 100644 index 0000000000..bef661f64f --- /dev/null +++ b/packages/notifications-panel/src/presenters/PanelPresenter.js @@ -0,0 +1,95 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { css } from "emotion"; +import { Panel } from "@hig/flyout"; +import ProgressRing from "@hig/progress-ring"; +import ThemeContext from "@hig/theme-context"; +import Typography from "@hig/typography"; +import { + UNMOUNTED, + EXITED, + ENTERING, + ENTERED, + EXITING, +} from "react-transition-group/Transition"; +import Button from "@hig/button" + +import stylesheet from "./stylesheet"; + +export default function PanelPresenter({ + children, + heading, + innerRef, + listMaxHeight, + loadingTransitionState, + onScroll, + refListWrapper, + stylesheet: customStylesheet, + markAllAsReadTitle, + onClickMarkAllAsRead +}) { + return ( + + {({ resolvedRoles }) => { + const styles = stylesheet( + { + transitionState: null, + loadingTransitionState, + stylesheet: customStylesheet, + }, + resolvedRoles + ); + return ( + + + {heading} + +
+
+ {children} +
+
+
+
+
+ ); + }} +
+ ); +} + +PanelPresenter.defaultProps = { + heading: "Notifications", + markAllAsReadTitle: "Mark all as read" +}; + +PanelPresenter.propTypes = { + children: PropTypes.node, + heading: PropTypes.node, + innerRef: PropTypes.func.isRequired, + listMaxHeight: PropTypes.string, + markAllAsReadTitle: PropTypes.string, + onClickMarkAllAsRead: PropTypes.func, + stylesheet: PropTypes.func, + loadingTransitionState: PropTypes.oneOf([ + UNMOUNTED, + EXITED, + ENTERING, + ENTERED, + EXITING, + ]), + onScroll: PropTypes.func, + refListWrapper: PropTypes.func, + stylesheet: PropTypes.func, +}; diff --git a/packages/notifications-panel/src/presenters/PanelPresenter.test.js b/packages/notifications-panel/src/presenters/PanelPresenter.test.js new file mode 100644 index 0000000000..b8d62f7b07 --- /dev/null +++ b/packages/notifications-panel/src/presenters/PanelPresenter.test.js @@ -0,0 +1,27 @@ +import { ENTERED } from "react-transition-group/Transition"; +import { takeSnapshotsOf } from "@hig/jest-preset/helpers"; + +const PanelPresenter = require("./PanelPresenter").default; + +describe("notifications-flyout/presenters/PanelPresenter", () => { + takeSnapshotsOf(PanelPresenter, [ + { + desc: "renders without props", + props: { + innerRef: () => {}, + }, + }, + { + desc: "renders with all props", + props: { + children: "foobar", + heading: "Heading", + innerRef: () => {}, + listMaxHeight: "3000px", + loadingTransitionState: ENTERED, + onScroll: () => {}, + refListWrapper: () => {}, + }, + }, + ]); +}); diff --git a/packages/notifications-panel/src/presenters/__snapshots__/DismissButtonPresenter.test.js.snap b/packages/notifications-panel/src/presenters/__snapshots__/DismissButtonPresenter.test.js.snap new file mode 100644 index 0000000000..8973a48077 --- /dev/null +++ b/packages/notifications-panel/src/presenters/__snapshots__/DismissButtonPresenter.test.js.snap @@ -0,0 +1,168 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`notifications-flyout/presenters/DismissButtonPresenter renders with all props 1`] = ` +.emotion-2 { + display: none; + position: absolute; + top: 0; + right: 0; +} + +.emotion-1 { + background-color: transparent; + border-color: transparent; + border-style: solid; + border-width: 1px; + display: inline-block; + position: relative; + cursor: pointer; + box-sizing: border-box; + border-radius: 2px; + padding: 0; + height: calc(20px + (8px * 2)); + line-height: calc(20px + (8px * 2)); + width: calc(20px + (8px * 2)); + outline: 0; + -webkit-transition-property: box-shadow,background-color; + transition-property: box-shadow,background-color; + -webkit-transition-duration: 0.3s,0.3s; + transition-duration: 0.3s,0.3s; + -webkit-transition-timing-function: cubic-bezier(0.4,0,0.2,1),cubic-bezier(0.4,0,0.2,1); + transition-timing-function: cubic-bezier(0.4,0,0.2,1),cubic-bezier(0.4,0,0.2,1); +} + +.emotion-1 svg * { + fill: #808080; + -webkit-transition-duration: 0.3s; + transition-duration: 0.3s; + -webkit-transition-property: fill; + transition-property: fill; +} + +.emotion-0 { + fill: #808080; + position: absolute; + left: 0; + right: 0; + margin: 0 auto; + top: 50%; + -webkit-transform: translateY(-50%); + -ms-transform: translateY(-50%); + transform: translateY(-50%); + pointer-events: none; +} + +.emotion-0 > * { + fill: #808080; +} + +
+ +
+`; + +exports[`notifications-flyout/presenters/DismissButtonPresenter renders without props 1`] = ` +.emotion-2 { + display: none; + position: absolute; + top: 0; + right: 0; +} + +.emotion-1 { + background-color: transparent; + border-color: transparent; + border-style: solid; + border-width: 1px; + display: inline-block; + position: relative; + cursor: pointer; + box-sizing: border-box; + border-radius: 2px; + padding: 0; + height: calc(20px + (8px * 2)); + line-height: calc(20px + (8px * 2)); + width: calc(20px + (8px * 2)); + outline: 0; + -webkit-transition-property: box-shadow,background-color; + transition-property: box-shadow,background-color; + -webkit-transition-duration: 0.3s,0.3s; + transition-duration: 0.3s,0.3s; + -webkit-transition-timing-function: cubic-bezier(0.4,0,0.2,1),cubic-bezier(0.4,0,0.2,1); + transition-timing-function: cubic-bezier(0.4,0,0.2,1),cubic-bezier(0.4,0,0.2,1); +} + +.emotion-1 svg * { + fill: #808080; + -webkit-transition-duration: 0.3s; + transition-duration: 0.3s; + -webkit-transition-property: fill; + transition-property: fill; +} + +.emotion-0 { + fill: #808080; + position: absolute; + left: 0; + right: 0; + margin: 0 auto; + top: 50%; + -webkit-transform: translateY(-50%); + -ms-transform: translateY(-50%); + transform: translateY(-50%); + pointer-events: none; +} + +.emotion-0 > * { + fill: #808080; +} + +
+ +
+`; diff --git a/packages/notifications-panel/src/presenters/__snapshots__/EmptyStatePresenter.test.js.snap b/packages/notifications-panel/src/presenters/__snapshots__/EmptyStatePresenter.test.js.snap new file mode 100644 index 0000000000..cb82cf626c --- /dev/null +++ b/packages/notifications-panel/src/presenters/__snapshots__/EmptyStatePresenter.test.js.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`notifications-flyout/presenters/EmptyStatePresenter renders with all props 1`] = ` +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.emotion-0 { + margin-top: 20px; +} + +.emotion-1 { + color: #3c3c3c; + display: block; + font-family: ArtifaktElement,sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 1.428571429; + margin: 0; + text-align: initial; +} + +
+ +

+ hello world +

+
+`; + +exports[`notifications-flyout/presenters/EmptyStatePresenter renders without props 1`] = ` +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.emotion-0 { + margin-top: 20px; +} + +.emotion-1 { + color: #3c3c3c; + display: block; + font-family: ArtifaktElement,sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 1.428571429; + margin: 0; + text-align: initial; +} + +
+ +

+ You currently have no notifications +

+
+`; diff --git a/packages/notifications-panel/src/presenters/__snapshots__/ImagePresenter.test.js.snap b/packages/notifications-panel/src/presenters/__snapshots__/ImagePresenter.test.js.snap new file mode 100644 index 0000000000..0982b99b6c --- /dev/null +++ b/packages/notifications-panel/src/presenters/__snapshots__/ImagePresenter.test.js.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`notifications-flyout/presenters/ImagePresenter renders with img props 1`] = ` +.emotion-0 { + height: 48px; + width: 48px; + overflow: hidden; + border-radius: 4px; +} + +hello +`; diff --git a/packages/notifications-panel/src/presenters/__snapshots__/IndicatorPresenter.test.js.snap b/packages/notifications-panel/src/presenters/__snapshots__/IndicatorPresenter.test.js.snap new file mode 100644 index 0000000000..6658868f4b --- /dev/null +++ b/packages/notifications-panel/src/presenters/__snapshots__/IndicatorPresenter.test.js.snap @@ -0,0 +1,216 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`notifications-flyout/presenters/IndicatorPresenter renders with all props 1`] = ` +.emotion-3 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + position: relative; +} + +.emotion-1 { + background-color: transparent; + border-color: transparent; + border-style: solid; + border-width: 1px; + display: inline-block; + position: relative; + cursor: pointer; + box-sizing: border-box; + border-radius: 2px; + padding: 0; + height: calc(20px + (8px * 2)); + line-height: calc(20px + (8px * 2)); + width: calc(20px + (8px * 2)); + outline: 0; + -webkit-transition-property: box-shadow,background-color; + transition-property: box-shadow,background-color; + -webkit-transition-duration: 0.3s,0.3s; + transition-duration: 0.3s,0.3s; + -webkit-transition-timing-function: cubic-bezier(0.4,0,0.2,1),cubic-bezier(0.4,0,0.2,1); + transition-timing-function: cubic-bezier(0.4,0,0.2,1),cubic-bezier(0.4,0,0.2,1); +} + +.emotion-1 svg * { + fill: #808080; + -webkit-transition-duration: 0.3s; + transition-duration: 0.3s; + -webkit-transition-property: fill; + transition-property: fill; +} + +.emotion-0 { + fill: #808080; + position: absolute; + left: 0; + right: 0; + margin: 0 auto; + top: 50%; + -webkit-transform: translateY(-50%); + -ms-transform: translateY(-50%); + transform: translateY(-50%); + pointer-events: none; +} + +.emotion-0 > * { + fill: #808080; +} + +.emotion-2 { + position: absolute; + top: 50%; + left: 50%; + height: 13px; + padding: 0 3px; + font-size: 11px; + line-height: 13px; + border: 1px solid #ffffff; + color: #ffffff; + background-color: #0696d7; + border-radius: 4px; + pointer-events: none; + font-weight: 400; + font-family: ArtifaktElement,sans-serif; + margin: 0; + display: block; +} + +
+ +
+ 3 +
+
+`; + +exports[`notifications-flyout/presenters/IndicatorPresenter renders without props 1`] = ` +.emotion-3 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + position: relative; +} + +.emotion-2 { + position: absolute; + top: 50%; + left: 50%; + height: 13px; + padding: 0 3px; + font-size: 11px; + line-height: 13px; + border: 1px solid #ffffff; + color: #ffffff; + background-color: #0696d7; + border-radius: 4px; + pointer-events: none; + font-weight: 400; + font-family: ArtifaktElement,sans-serif; + margin: 0; + display: none; +} + +.emotion-1 { + background-color: transparent; + border-color: transparent; + border-style: solid; + border-width: 1px; + display: inline-block; + position: relative; + cursor: pointer; + box-sizing: border-box; + border-radius: 2px; + padding: 0; + height: calc(20px + (8px * 2)); + line-height: calc(20px + (8px * 2)); + width: calc(20px + (8px * 2)); + outline: 0; + -webkit-transition-property: box-shadow,background-color; + transition-property: box-shadow,background-color; + -webkit-transition-duration: 0.3s,0.3s; + transition-duration: 0.3s,0.3s; + -webkit-transition-timing-function: cubic-bezier(0.4,0,0.2,1),cubic-bezier(0.4,0,0.2,1); + transition-timing-function: cubic-bezier(0.4,0,0.2,1),cubic-bezier(0.4,0,0.2,1); +} + +.emotion-1 svg * { + fill: #808080; + -webkit-transition-duration: 0.3s; + transition-duration: 0.3s; + -webkit-transition-property: fill; + transition-property: fill; +} + +.emotion-0 { + fill: #808080; + position: absolute; + left: 0; + right: 0; + margin: 0 auto; + top: 50%; + -webkit-transform: translateY(-50%); + -ms-transform: translateY(-50%); + transform: translateY(-50%); + pointer-events: none; +} + +.emotion-0 > * { + fill: #808080; +} + +
+ +
+
+`; diff --git a/packages/notifications-panel/src/presenters/__snapshots__/NotificationPresenter.test.js.snap b/packages/notifications-panel/src/presenters/__snapshots__/NotificationPresenter.test.js.snap new file mode 100644 index 0000000000..3686d15fdf --- /dev/null +++ b/packages/notifications-panel/src/presenters/__snapshots__/NotificationPresenter.test.js.snap @@ -0,0 +1,417 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`notifications-flyout/presenters/NotificationPresenter renders with all props 1`] = ` +.emotion-6 { + position: relative; + overflow: hidden; + -webkit-transition-property: height,opacity; + transition-property: height,opacity; + -webkit-transition-duration: 300ms; + transition-duration: 300ms; + -webkit-transition-timing-function: ease; + transition-timing-function: ease; +} + +.emotion-6:last-child { + border-bottom: none; +} + +.emotion-4 { + margin: 12px; + word-wrap: break-word; + overflow: hidden; +} + +.emotion-0 { + color: #3c3c3c; + display: block; + font-family: ArtifaktElement,sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 1.428571429; + margin: 0; + text-align: initial; +} + +.emotion-0 ul, +.emotion-0 ol { + padding-left: 24px; +} + +.emotion-0 ul li { + list-style: none; +} + +.emotion-0 ul li:before { + content: '\\B7'; + vertical-align: middle; + font-size: 20px; + padding-right: 12px; + margin-left: -14px; +} + +.emotion-0 h1 { + color: #3c3c3c; + display: block; + font-family: ArtifaktElement,sans-serif; + font-size: 28px; + font-weight: 400; + line-height: 1.285714286; + margin: 0; + text-align: initial; +} + +.emotion-0 h2 { + color: #3c3c3c; + display: block; + font-family: ArtifaktElement,sans-serif; + font-size: 24px; + font-weight: 400; + line-height: 1.25; + margin: 0; + text-align: initial; +} + +.emotion-0 h3 { + color: #3c3c3c; + display: block; + font-family: ArtifaktElement,sans-serif; + font-size: 20px; + font-weight: 600; + line-height: 1.3; + margin: 0; + text-align: initial; +} + +.emotion-0 a { + -webkit-text-decoration: none; + text-decoration: none; + cursor: pointer; + color: #006eaf; + outline: none; +} + +.emotion-0 a:hover { + color: #006eaf; + -webkit-text-decoration: underline; + text-decoration: underline; + -webkit-text-decoration-color: #006eaf; + text-decoration-color: #006eaf; +} + +.emotion-0 a:focus { + color: #006eaf; + outline: solid 2px rgba(6,150,215,0.35); +} + +.emotion-0 p { + color: #3c3c3c; + display: block; + font-family: ArtifaktElement,sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 1.428571429; + margin: 0 0 12px 0; + text-align: initial; +} + +.emotion-0 h1 + p, +.emotion-0 h2 + p, +.emotion-0 h3 + p { + color: #3c3c3c; + display: block; + font-family: ArtifaktElement,sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 1.428571429; + margin: 16px 0 12px 0; + text-align: initial; +} + +.emotion-0 b, +.emotion-0 strong { + font-weight: 700; +} + +.emotion-5 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + border-bottom: 1px solid rgba(60,60,60,0.25); + border-left: 3px solid #bec8d2; + border-left-color: #6a9728; +} + +.emotion-3 { + display: none; + position: absolute; + top: 0; + right: 0; +} + +.emotion-2 { + background-color: transparent; + border-color: transparent; + border-style: solid; + border-width: 1px; + display: inline-block; + position: relative; + cursor: pointer; + box-sizing: border-box; + border-radius: 2px; + padding: 0; + height: calc(20px + (8px * 2)); + line-height: calc(20px + (8px * 2)); + width: calc(20px + (8px * 2)); + outline: 0; + -webkit-transition-property: box-shadow,background-color; + transition-property: box-shadow,background-color; + -webkit-transition-duration: 0.3s,0.3s; + transition-duration: 0.3s,0.3s; + -webkit-transition-timing-function: cubic-bezier(0.4,0,0.2,1),cubic-bezier(0.4,0,0.2,1); + transition-timing-function: cubic-bezier(0.4,0,0.2,1),cubic-bezier(0.4,0,0.2,1); +} + +.emotion-2 svg * { + fill: #808080; + -webkit-transition-duration: 0.3s; + transition-duration: 0.3s; + -webkit-transition-property: fill; + transition-property: fill; +} + +.emotion-1 { + fill: #808080; + position: absolute; + left: 0; + right: 0; + margin: 0 auto; + top: 50%; + -webkit-transform: translateY(-50%); + -ms-transform: translateY(-50%); + transform: translateY(-50%); + pointer-events: none; +} + +.emotion-1 > * { + fill: #808080; +} + +
+
+
+
+ foobar +
+ 2018-08-17 +
+ +
+
+
+
+`; + +exports[`notifications-flyout/presenters/NotificationPresenter renders without props 1`] = ` +.emotion-3 { + position: relative; + overflow: hidden; + -webkit-transition-property: height,opacity; + transition-property: height,opacity; + -webkit-transition-duration: 300ms; + transition-duration: 300ms; + -webkit-transition-timing-function: ease; + transition-timing-function: ease; +} + +.emotion-3:last-child { + border-bottom: none; +} + +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + border-bottom: 1px solid rgba(60,60,60,0.25); + border-left: 3px solid #bec8d2; + border-left-color: rgba(128,128,128,0.4); +} + +.emotion-1 { + margin: 12px; + word-wrap: break-word; + overflow: hidden; +} + +.emotion-0 { + color: #3c3c3c; + display: block; + font-family: ArtifaktElement,sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 1.428571429; + margin: 0; + text-align: initial; +} + +.emotion-0 ul, +.emotion-0 ol { + padding-left: 24px; +} + +.emotion-0 ul li { + list-style: none; +} + +.emotion-0 ul li:before { + content: '\\B7'; + vertical-align: middle; + font-size: 20px; + padding-right: 12px; + margin-left: -14px; +} + +.emotion-0 h1 { + color: #3c3c3c; + display: block; + font-family: ArtifaktElement,sans-serif; + font-size: 28px; + font-weight: 400; + line-height: 1.285714286; + margin: 0; + text-align: initial; +} + +.emotion-0 h2 { + color: #3c3c3c; + display: block; + font-family: ArtifaktElement,sans-serif; + font-size: 24px; + font-weight: 400; + line-height: 1.25; + margin: 0; + text-align: initial; +} + +.emotion-0 h3 { + color: #3c3c3c; + display: block; + font-family: ArtifaktElement,sans-serif; + font-size: 20px; + font-weight: 600; + line-height: 1.3; + margin: 0; + text-align: initial; +} + +.emotion-0 a { + -webkit-text-decoration: none; + text-decoration: none; + cursor: pointer; + color: #006eaf; + outline: none; +} + +.emotion-0 a:hover { + color: #006eaf; + -webkit-text-decoration: underline; + text-decoration: underline; + -webkit-text-decoration-color: #006eaf; + text-decoration-color: #006eaf; +} + +.emotion-0 a:focus { + color: #006eaf; + outline: solid 2px rgba(6,150,215,0.35); +} + +.emotion-0 p { + color: #3c3c3c; + display: block; + font-family: ArtifaktElement,sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 1.428571429; + margin: 0 0 12px 0; + text-align: initial; +} + +.emotion-0 h1 + p, +.emotion-0 h2 + p, +.emotion-0 h3 + p { + color: #3c3c3c; + display: block; + font-family: ArtifaktElement,sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 1.428571429; + margin: 16px 0 12px 0; + text-align: initial; +} + +.emotion-0 b, +.emotion-0 strong { + font-weight: 700; +} + +
+
+
+
+
+
+
+`; diff --git a/packages/notifications-panel/src/presenters/__snapshots__/PanelPresenter.test.js.snap b/packages/notifications-panel/src/presenters/__snapshots__/PanelPresenter.test.js.snap new file mode 100644 index 0000000000..7642b52334 --- /dev/null +++ b/packages/notifications-panel/src/presenters/__snapshots__/PanelPresenter.test.js.snap @@ -0,0 +1,212 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`notifications-flyout/presenters/PanelPresenter renders with all props 1`] = ` +.emotion-1 { + width: 300px; + overflow-y: auto; + overflow-x: hidden; +} + +.emotion-4 { + background-color: #ffffff; + border-radius: 4px; + border: none; + box-shadow: 0 0 16px rgba(0,0,0,0.2); +} + +.emotion-3 { + position: relative; +} + +.emotion-0 { + color: #3c3c3c; + display: block; + font-family: ArtifaktElement,sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 1.428571429; + margin: 0; + text-align: initial; +} + +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + box-sizing: border-box; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + height: 44px; + overflow: hidden; + background-color: transparent; + opacity: 1; + -webkit-transition-property: height,opacity; + transition-property: height,opacity; + -webkit-transition-duration: 300ms; + transition-duration: 300ms; + -webkit-transition-timing-function: ease; + transition-timing-function: ease; + pointer-events: none; +} + +
+
+
+ Heading +
+
+
+ foobar +
+
+
+
+
+
+
+`; + +exports[`notifications-flyout/presenters/PanelPresenter renders without props 1`] = ` +.emotion-1 { + width: 300px; + overflow-y: auto; + overflow-x: hidden; +} + +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + box-sizing: border-box; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + height: 0; + overflow: hidden; + background-color: transparent; + opacity: 0; + -webkit-transition-property: height,opacity; + transition-property: height,opacity; + -webkit-transition-duration: 300ms; + transition-duration: 300ms; + -webkit-transition-timing-function: ease; + transition-timing-function: ease; + pointer-events: none; +} + +.emotion-4 { + background-color: #ffffff; + border-radius: 4px; + border: none; + box-shadow: 0 0 16px rgba(0,0,0,0.2); +} + +.emotion-3 { + position: relative; +} + +.emotion-0 { + color: #3c3c3c; + display: block; + font-family: ArtifaktElement,sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 1.428571429; + margin: 0; + text-align: initial; +} + +
+
+
+ Notifications +
+
+
+
+ +
+
+`; diff --git a/packages/notifications-panel/src/presenters/stylesheet.js b/packages/notifications-panel/src/presenters/stylesheet.js new file mode 100644 index 0000000000..2b113452e4 --- /dev/null +++ b/packages/notifications-panel/src/presenters/stylesheet.js @@ -0,0 +1,155 @@ +import { types } from "../types"; + +function getRulesByTransitionStatus(transitionStatus) { + if (transitionStatus === "exiting") { + return { height: 0, opacity: 0 }; + } + if (transitionStatus === "exited") { + return { display: "none" }; + } + return {}; +} + +function getRulesByType(type, themeData) { + switch (type) { + case types.ERROR: + return { borderLeftColor: themeData["colorScheme.status.error"] }; + case types.WARNING: + return { borderLeftColor: themeData["colorScheme.status.warning"] }; + case types.SUCCESS: + return { borderLeftColor: themeData["colorScheme.status.success"] }; + default: + return { + borderLeftColor: themeData["basics.colors.primary.autodeskBlue.500"], + }; + } +} + +export default function stylesheet(props, themeData) { + const { + hasHover, + loadingTransitionState, + showCount, + stylesheet: customStylesheet, + transitionStatus, + type, + unread, + } = props; + const isEntering = + loadingTransitionState === `entering` || + loadingTransitionState === `entered`; + const styles = { + dismissButton: { + display: `none`, + position: `absolute`, + top: 0, + right: 0, + ...(hasHover ? { display: `block` } : {}), + }, + notification: { + position: `relative`, + overflow: `hidden`, + transitionProperty: `height, opacity`, + transitionDuration: `300ms`, + transitionTimingFunction: `ease`, + "&:last-child": { + borderBottom: `none`, + }, + ...(transitionStatus ? getRulesByTransitionStatus(transitionStatus) : {}), + ...(hasHover + ? { backgroundColor: themeData["colorScheme.surface.level300"] } + : {}), + }, + notificationContent: { + display: `flex`, + borderBottom: `1px solid ${themeData["divider.heavyColor"]}`, + borderLeft: `3px solid #bec8d2`, + ...(unread + ? getRulesByType(type, themeData) + : { borderLeftColor: `rgba(128, 128, 128, 0.4)` }), + }, + notificationContentImage: { + margin: `${themeData["density.spacings.small"]} 0 ${themeData["density.spacings.small"]} ${themeData["density.spacings.small"]}`, + }, + notificationContentText: { + margin: `${themeData["density.spacings.small"]}`, + wordWrap: `break-word`, + overflow: `hidden`, + }, + panelTitle: { + padding: `${themeData["density.spacings.small"]} + ${themeData["density.spacings.large"]}`, + borderBottom: `1px solid ${themeData["divider.heavyColor"]}`, + }, + panelContainer: { + width: `100%`, + overflowY: `auto`, + overflowX: `hidden`, + }, + panelLoading: { + display: `flex`, + boxSizing: `border-box`, + justifyContent: `center`, + alignItems: `center`, + height: 0, + overflow: `hidden`, + backgroundColor: `transparent`, + opacity: `0`, + transitionProperty: `height, opacity`, + transitionDuration: `300ms`, + transitionTimingFunction: `ease`, + pointerEvents: `none`, + ...(isEntering ? { height: `44px`, opacity: 1 } : {}), + }, + notificationsFooter:{ + height: '2.5rem', + backgroundColor: 'white', + bottom: '0', + left: '0', + right: '0', + padding: '0.5rem', + marginBottom: '0px' + }, + indicator: { + display: `flex`, + position: `relative`, + }, + indicatorCount: { + position: `absolute`, + top: `50%`, + left: `50%`, + height: `13px`, + padding: `0 3px`, + fontSize: `11px`, + lineHeight: `13px`, + border: `1px solid ${themeData["colorScheme.surface.level100"]}`, + color: themeData["basics.colors.primary.white"], + backgroundColor: `#0696d7`, + borderRadius: `4px`, + pointerEvents: `none`, + fontWeight: 400, + fontFamily: themeData["notifications.fontFamily"], + margin: 0, + ...(showCount ? { display: `block` } : { display: `none` }), + }, + image: { + height: `48px`, + width: `48px`, + overflow: `hidden`, + borderRadius: `4px`, + }, + emptyState: { + display: `flex`, + flexDirection: `column`, + alignItems: `center`, + }, + emptyStateImage: { + marginTop: `20px`, + }, + emptyStateMessage: { + margin: `20px 0 37px`, + }, + }; + + return customStylesheet ? customStylesheet(styles, props, themeData) : styles; +} diff --git a/packages/notifications-panel/src/presenters/stylesheet.test.js b/packages/notifications-panel/src/presenters/stylesheet.test.js new file mode 100644 index 0000000000..4e00fe8c55 --- /dev/null +++ b/packages/notifications-panel/src/presenters/stylesheet.test.js @@ -0,0 +1,78 @@ +import stylesheet from "./stylesheet"; + +describe("notifications-flyout/stylesheet", () => { + const styles = stylesheet({}, {}, {}, {}); + + it("returns an object", () => { + expect(styles).toEqual(expect.any(Object)); + }); + + it("returned object contains property of dismissButton", () => { + expect(styles).toHaveProperty("dismissButton", expect.any(Object)); + }); + + it("returned object contains property of notification", () => { + expect(styles).toHaveProperty("notification", expect.any(Object)); + }); + + it("returned object contains property of notificationContent", () => { + expect(styles).toHaveProperty("notificationContent", expect.any(Object)); + }); + + it("returned object contains property of notificationContentImage", () => { + expect(styles).toHaveProperty( + "notificationContentImage", + expect.any(Object) + ); + }); + + it("returned object contains property of notificationContentText", () => { + expect(styles).toHaveProperty( + "notificationContentText", + expect.any(Object) + ); + }); + + it("returned object contains property of panelTitle", () => { + expect(styles).toHaveProperty("panelTitle", expect.any(Object)); + }); + + it("returned object contains property of panelContainer", () => { + expect(styles).toHaveProperty("panelContainer", expect.any(Object)); + }); + + it("returned object contains property of panelLoading", () => { + expect(styles).toHaveProperty("panelLoading", expect.any(Object)); + }); + + it("returned object contains property of indicator", () => { + expect(styles).toHaveProperty("indicator", expect.any(Object)); + }); + + it("returned object contains property of indicatorCount", () => { + expect(styles).toHaveProperty("indicatorCount", expect.any(Object)); + }); + + it("returned object contains property of image", () => { + expect(styles).toHaveProperty("image", expect.any(Object)); + }); + + it("returned object contains property of emptyState", () => { + expect(styles).toHaveProperty("emptyState", expect.any(Object)); + }); + + it("returned object contains property of emptyStateImage", () => { + expect(styles).toHaveProperty("emptyStateImage", expect.any(Object)); + }); + + it("returned object contains property of emptyStateMessage", () => { + expect(styles).toHaveProperty("emptyStateMessage", expect.any(Object)); + }); + + it("returns the custom stylesheet", () => { + const props = { + stylesheet: () => ({ padding: 0 }), + }; + expect(stylesheet(props, {})).toEqual({ padding: 0 }); + }); +}); diff --git a/packages/notifications-panel/src/types.js b/packages/notifications-panel/src/types.js new file mode 100644 index 0000000000..273ccb0b0f --- /dev/null +++ b/packages/notifications-panel/src/types.js @@ -0,0 +1,8 @@ +export const types = Object.freeze({ + ERROR: "error", + PRIMARY: "primary", + SUCCESS: "success", + WARNING: "warning", +}); + +export const AVAILABLE_TYPES = Object.freeze(Object.values(types));