Skip to content

Commit adf98ae

Browse files
authored
feat: notifications dismiss animations (#387)
* chore: add removed field to state * feat: add removed flag for notifications * feat: expose dismiss prop * fix: simplify percent calc for progress * feat: use store types for notification * fix: improve dismissed prop naming * feat: add notification animations * chore: expose dev server * fix: fix white bg issue on terminal * fix: adjust animation timings
1 parent b2c52d7 commit adf98ae

File tree

8 files changed

+205
-55
lines changed

8 files changed

+205
-55
lines changed

web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282
"workbox-streams": "^6.5.4"
8383
},
8484
"scripts": {
85-
"start": "npx vite",
85+
"start": "npx vite --host",
8686
"build": "npx vite build",
8787
"serve": "npx vite preview",
8888
"lint": "npx eslint .",

web/src/components/features/inspector/Console/Console.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
inset: 0;
1212
}
1313

14+
/** Fix white background in viewport **/
15+
.app-Console__xterm .xterm-screen {
16+
background: var(--terminal-bg);
17+
}
18+
1419
.app-Console .terminal > * {
1520
padding: 0 var(--terminal-padding-x);
1621
}

web/src/components/features/inspector/Console/Console.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ export const Console: React.FC<Props> = ({ fontFamily, fontSize, status, backend
226226
}, [terminal?.textarea, setIsFocused])
227227

228228
return (
229-
<div className="app-Console">
229+
<div className="app-Console" style={{ '--terminal-bg': theme.background } as any}>
230230
<CopyButton hidden={!isFocused} onClick={copySelection} />
231231
<XTerm
232232
ref={xtermRef}

web/src/components/modals/Notification/Notification.tsx

Lines changed: 59 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react'
1+
import React, { useEffect, useRef } from 'react'
22
import {
33
Stack,
44
IconButton,
@@ -7,40 +7,20 @@ import {
77
DefaultButton,
88
PrimaryButton,
99
useTheme,
10+
MotionTimings,
1011
} from '@fluentui/react'
1112
import { FontIcon } from '@fluentui/react/lib/Icon'
13+
import {
14+
type Notification as NotificationModel,
15+
type NotificationAction,
16+
NotificationType,
17+
} from '~/store/notifications'
1218

1319
import './Notification.css'
1420

15-
export enum NotificationType {
16-
None = '',
17-
Info = 'info',
18-
Warning = 'warning',
19-
Error = 'error',
20-
}
21-
22-
interface ProgressState {
23-
indeterminate?: boolean
24-
total?: number
25-
current?: number
26-
}
27-
28-
interface NotificationAction {
29-
label: string
30-
key: string
31-
primary?: boolean
32-
onClick?: () => void
33-
}
34-
35-
export interface NotificationProps {
36-
id: number | string
37-
type?: NotificationType
38-
title: string
39-
description?: string
40-
canDismiss?: boolean
41-
progress?: ProgressState
42-
onClose?: () => void
43-
actions?: NotificationAction[]
21+
export interface NotificationProps extends NotificationModel {
22+
onDismiss?: (id: string) => void
23+
onDismissed?: (id: string) => void
4424
}
4525

4626
const iconColorPaletteMap: { [k in NotificationType]: keyof ISemanticColors } = {
@@ -57,14 +37,18 @@ const statusIconMapping: { [k in NotificationType]: string } = {
5737
[NotificationType.None]: 'info',
5838
}
5939

40+
/**
41+
* Computes current progress percentage and returns float value between 0 and 1.
42+
*
43+
* Returns undefined if there is no progress data available.
44+
*/
6045
const getPercentComplete = (progress: NotificationProps['progress']): number | undefined => {
6146
if (!progress || progress?.indeterminate) {
6247
return
6348
}
6449

6550
const { current, total } = progress
66-
const percentage = (current! * 100) / total!
67-
return percentage / 100
51+
return current! / total!
6852
}
6953

7054
const NotificationActionButton: React.FC<Omit<NotificationAction, 'key'>> = ({ label, primary, onClick }) => {
@@ -79,12 +63,52 @@ export const Notification: React.FunctionComponent<NotificationProps> = ({
7963
description,
8064
canDismiss = true,
8165
type = NotificationType.Info,
82-
onClose,
8366
actions,
67+
dismissed,
68+
onDismiss,
69+
onDismissed,
8470
}) => {
71+
const elementRef = useRef<HTMLDivElement | null>(null)
8572
const { semanticColors, fonts, ...theme } = useTheme()
73+
74+
useEffect(() => {
75+
const { current: elem } = elementRef
76+
if (!dismissed || !elem) {
77+
return
78+
}
79+
80+
// Animate element swipe out + shrink space around.
81+
// Height should be extracted from JS until "calc-size" is not available.
82+
const height = elem.clientHeight
83+
const animation = elem.animate(
84+
[
85+
{
86+
opacity: '1',
87+
maxHeight: `${height}px`,
88+
offset: 0,
89+
},
90+
{
91+
opacity: '0.5',
92+
transform: 'translate3d(120%, 0, 0)',
93+
maxHeight: `${height}px`,
94+
offset: 0.5,
95+
},
96+
{
97+
opacity: '0',
98+
transform: 'translate3d(120%, 0, 0)',
99+
maxHeight: '0',
100+
offset: 1.0,
101+
},
102+
],
103+
{ duration: 200, fill: 'forwards', easing: MotionTimings.standard },
104+
)
105+
106+
animation.onfinish = () => onDismissed?.(id)
107+
animation.play()
108+
}, [id, dismissed, elementRef, onDismissed])
86109
return (
87110
<div
111+
ref={elementRef}
88112
className="Notification"
89113
data-notification-id={id}
90114
style={{
@@ -108,7 +132,7 @@ export const Notification: React.FunctionComponent<NotificationProps> = ({
108132
<IconButton
109133
title="Close"
110134
ariaLabel="Close notification"
111-
onClick={onClose}
135+
onClick={() => onDismiss?.(id)}
112136
style={{
113137
color: 'inherit',
114138
width: 'auto',
@@ -143,7 +167,7 @@ export const Notification: React.FunctionComponent<NotificationProps> = ({
143167
key={key}
144168
onClick={() => {
145169
onClick?.()
146-
onClose?.()
170+
onDismiss?.(id)
147171
}}
148172
/>
149173
))}
Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,44 @@
1-
import React from 'react'
2-
import { connect } from 'react-redux'
3-
import { type StateDispatch, type State } from '~/store'
4-
import { type NotificationsState, newRemoveNotificationAction } from '~/store/notifications'
1+
import React, { useCallback } from 'react'
2+
import { type StateDispatch, connect } from '~/store'
3+
import {
4+
type NotificationsState,
5+
newDeleteRemovedNotificationAction,
6+
newRemoveNotificationAction,
7+
} from '~/store/notifications'
58
import { Notification } from './Notification'
69
import './NotificationHost.css'
710

8-
interface Props {
11+
interface StateProps {
912
notifications?: NotificationsState
10-
dispatch?: StateDispatch
1113
}
1214

15+
interface Props extends StateProps {
16+
dispatch: StateDispatch
17+
}
18+
19+
/**
20+
* Component responsible for hosting and displaying notifications from store.
21+
*/
1322
const NotificationHostBase: React.FunctionComponent<Props> = ({ notifications, dispatch }) => {
23+
const dismissNotification = useCallback((id: string) => dispatch(newRemoveNotificationAction(id)), [dispatch])
24+
const deleteNotification = useCallback((id: string) => dispatch(newDeleteRemovedNotificationAction(id)), [dispatch])
25+
1426
return (
1527
<div className="NotificationHost">
1628
{notifications
1729
? Object.entries(notifications).map(([key, notification]) => (
1830
<Notification
1931
{...notification}
2032
key={key}
21-
onClose={() => {
22-
dispatch?.(newRemoveNotificationAction(key))
23-
}}
33+
onDismiss={dismissNotification}
34+
onDismissed={deleteNotification}
2435
/>
2536
))
2637
: null}
2738
</div>
2839
)
2940
}
3041

31-
export const NotificationHost = connect<Props, any, any, State>(({ notifications }) => ({ notifications }))(
42+
export const NotificationHost = connect<StateProps, {}>(({ notifications }) => ({ notifications }))(
3243
NotificationHostBase,
3344
)

web/src/store/notifications/actions.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
import { type Notification } from './state'
1+
import type { Notification } from './state'
22

33
export enum ActionType {
44
NEW_NOTIFICATION = 'NEW_NOTIFICATION',
55
ADD_NOTIFICATIONS = 'ADD_NOTIFICATIONS',
66
UPDATE_NOTIFICATION = 'UPDATE_NOTIFICATION',
77
REMOVE_NOTIFICATION = 'REMOVE_NOTIFICATION',
8+
DELETE_REMOVED_NOTIFICATION = 'DELETE_REMOVED_NOTIFICATION',
89
}
910

11+
/**
12+
* Generates a new unique notification ID.
13+
* @returns
14+
*/
1015
export const newNotificationId = () => `${Date.now().toString(36)}_${Math.random().toString(36).substring(2)}`
1116

1217
/**
@@ -20,12 +25,34 @@ export const newAddNotificationAction = (notification: Notification, updateOnly
2025
payload: notification,
2126
})
2227

28+
/**
29+
* Returns a bulk add notifications action.
30+
* @param notifications List of notifications to add.
31+
*/
2332
export const newAddNotificationsAction = (notifications: Notification[]) => ({
2433
type: ActionType.ADD_NOTIFICATIONS,
2534
payload: notifications,
2635
})
2736

37+
/**
38+
* Returns an action to dismiss notification.
39+
* @param id Notification ID
40+
*/
2841
export const newRemoveNotificationAction = (id: string) => ({
2942
type: ActionType.REMOVE_NOTIFICATION,
3043
payload: id,
3144
})
45+
46+
/**
47+
* Returns an action to delete notification from store.
48+
*
49+
* Intented to use **only by notification host** responsible for drawing notifications.
50+
*
51+
* Regular clients should use `newRemoveNotificationAction` instead.
52+
*
53+
* @param id Notification ID
54+
*/
55+
export const newDeleteRemovedNotificationAction = (id: string) => ({
56+
type: ActionType.DELETE_REMOVED_NOTIFICATION,
57+
payload: id,
58+
})

web/src/store/notifications/reducers.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,16 @@ const reducers = mapByAction<NotificationsState>(
2020

2121
return { ...s, [payload.id]: payload }
2222
},
23-
[ActionType.REMOVE_NOTIFICATION]: (s: NotificationsState, { payload }: Action<string>) => {
23+
[ActionType.REMOVE_NOTIFICATION]: (s: NotificationsState, { payload: id }: Action<string>) => {
24+
if (!s[id]) {
25+
return s
26+
}
27+
28+
const newNotifications = { ...s }
29+
newNotifications[id].dismissed = true
30+
return newNotifications
31+
},
32+
[ActionType.DELETE_REMOVED_NOTIFICATION]: (s: NotificationsState, { payload }: Action<string>) => {
2433
const newNotifications = { ...s }
2534
delete newNotifications[payload]
2635
return newNotifications

0 commit comments

Comments
 (0)