[fix] improves UX of notifications for mobile devices (#6551)
* fix: implements z-axis stacked notifications and style improvements, resolves #6539 * [autofix.ci] apply automated fixes * chore: add release notes * Update VRT screenshots Auto-generated by VRT workflow PR: #6551 * chore: remove opacity * [autofix.ci] apply automated fixes * fix: get first notification text (behind the latest notification) * Update VRT screenshots Auto-generated by VRT workflow PR: #6551 * chore: add implicit interactive prop --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
@@ -350,8 +350,8 @@ budgetTypes.forEach(budgetType => {
|
||||
await expect(budgetedButton).toHaveText(
|
||||
amountToCurrency(amountToTemplate),
|
||||
);
|
||||
const notification = page.getByRole('alert').first();
|
||||
await expect(notification).toContainText(templateNotes);
|
||||
const templateNotification = page.getByRole('alert').nth(1);
|
||||
await expect(templateNotification).toContainText(templateNotes);
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
@@ -7,7 +7,7 @@ import React, {
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { animated, useSpring } from 'react-spring';
|
||||
import { animated, useSpring, to } from 'react-spring';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
|
||||
import { Button, ButtonWithLoading } from '@actual-app/components/button';
|
||||
@@ -30,6 +30,14 @@ import {
|
||||
} from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useSelector, useDispatch } from '@desktop-client/redux';
|
||||
|
||||
// Notification stacking configuration
|
||||
const MAX_VISIBLE_NOTIFICATIONS = 3; // Maximum number of notifications visible in the stack
|
||||
const SCALE_MULTIPLIER = 0.05; // Scale reduction per stacked notification
|
||||
const OPACITY_MULTIPLIER = 0.2; // Opacity reduction per stacked notification
|
||||
const MIN_OPACITY = 1; // Minimum opacity for stacked notifications
|
||||
const Y_OFFSET_PER_LEVEL = -20; // Vertical offset in pixels per stacked notification
|
||||
const BASE_Z_INDEX = 10; // Base z-index for notification stacking
|
||||
|
||||
function compileMessage(
|
||||
message: string,
|
||||
actions: Record<string, () => void>,
|
||||
@@ -91,9 +99,13 @@ function compileMessage(
|
||||
function Notification({
|
||||
notification,
|
||||
onRemove,
|
||||
index,
|
||||
isInteractive,
|
||||
}: {
|
||||
notification: NotificationWithId;
|
||||
onRemove: () => void;
|
||||
index: number;
|
||||
isInteractive: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
@@ -134,8 +146,25 @@ function Notification({
|
||||
? { minHeight: styles.mobileMinHeight }
|
||||
: {};
|
||||
|
||||
// Calculate stacking properties based on index (scales for any number of notifications)
|
||||
const scale = 1.0 - index * SCALE_MULTIPLIER;
|
||||
const stackOpacity = Math.max(MIN_OPACITY, 1.0 - index * OPACITY_MULTIPLIER);
|
||||
const zIndex = BASE_Z_INDEX - index;
|
||||
|
||||
const yOffset = index * Y_OFFSET_PER_LEVEL;
|
||||
|
||||
const [isSwiped, setIsSwiped] = useState(false);
|
||||
const [spring, api] = useSpring(() => ({ x: 0, opacity: 1 }));
|
||||
const [spring, api] = useSpring(() => ({
|
||||
x: 0,
|
||||
y: yOffset,
|
||||
opacity: stackOpacity,
|
||||
scale,
|
||||
}));
|
||||
|
||||
// Update scale, opacity, and y-position when index changes
|
||||
useEffect(() => {
|
||||
api.start({ scale, opacity: stackOpacity, y: yOffset });
|
||||
}, [index, scale, stackOpacity, yOffset, api]);
|
||||
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwiping: ({ deltaX }) => {
|
||||
@@ -168,24 +197,34 @@ function Notification({
|
||||
<animated.div
|
||||
role="alert"
|
||||
style={{
|
||||
...spring,
|
||||
marginTop: 10,
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex,
|
||||
// Combine translateX, translateY and scale transforms
|
||||
transform: to(
|
||||
[spring.x, spring.y, spring.scale],
|
||||
(x, y, s) => `translateX(${x}px) translateY(${y}px) scale(${s})`,
|
||||
),
|
||||
opacity: spring.opacity,
|
||||
pointerEvents: isInteractive ? 'auto' : 'none',
|
||||
color: positive
|
||||
? theme.noticeText
|
||||
: error
|
||||
? theme.errorTextDark
|
||||
: theme.warningTextDark,
|
||||
// Prevents scrolling conflicts
|
||||
touchAction: 'none',
|
||||
touchAction: isInteractive ? 'none' : 'auto',
|
||||
}}
|
||||
{...swipeHandlers}
|
||||
{...(isInteractive ? swipeHandlers : {})}
|
||||
>
|
||||
<SpaceBetween
|
||||
wrap={false}
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
position: 'relative',
|
||||
padding: '14px 14px',
|
||||
paddingRight: 40,
|
||||
borderRadius: 8,
|
||||
...styles.mediumText,
|
||||
backgroundColor: positive
|
||||
? theme.noticeBackgroundLight
|
||||
@@ -204,19 +243,85 @@ function Notification({
|
||||
'& a': { color: 'currentColor' },
|
||||
}}
|
||||
>
|
||||
<SpaceBetween direction="vertical" style={{ alignItems: 'flex-start' }}>
|
||||
{/* Close button in top right corner */}
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Close')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
padding: 8,
|
||||
color: 'currentColor',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
onPress={onRemove}
|
||||
>
|
||||
<SvgDelete style={{ width: 10, height: 10 }} />
|
||||
</Button>
|
||||
|
||||
{/* Content and action button layout */}
|
||||
<SpaceBetween
|
||||
direction="vertical"
|
||||
gap={10}
|
||||
style={{ alignItems: 'flex-start' }}
|
||||
>
|
||||
{title && (
|
||||
<View
|
||||
style={{
|
||||
...styles.mediumText,
|
||||
fontWeight: 700,
|
||||
marginBottom: 10,
|
||||
paddingRight: 20,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</View>
|
||||
)}
|
||||
<View>{processedMessage}</View>
|
||||
|
||||
{/* Message and button on same row */}
|
||||
<SpaceBetween
|
||||
wrap={false}
|
||||
gap={10}
|
||||
style={{ width: '100%', alignItems: 'flex-start' }}
|
||||
>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>{processedMessage}</View>
|
||||
{button && (
|
||||
<ButtonWithLoading
|
||||
variant="bare"
|
||||
isLoading={loading}
|
||||
onPress={async () => {
|
||||
setLoading(true);
|
||||
await button.action();
|
||||
onRemove();
|
||||
setLoading(false);
|
||||
}}
|
||||
className={css({
|
||||
backgroundColor: 'transparent',
|
||||
border: `1px solid ${
|
||||
positive
|
||||
? theme.noticeBorder
|
||||
: error
|
||||
? theme.errorBorder
|
||||
: theme.warningBorder
|
||||
}`,
|
||||
color: 'currentColor',
|
||||
...styles.mediumText,
|
||||
flexShrink: 0,
|
||||
'&[data-hovered], &[data-pressed]': {
|
||||
backgroundColor: positive
|
||||
? theme.noticeBackground
|
||||
: error
|
||||
? theme.errorBackground
|
||||
: theme.warningBackground,
|
||||
},
|
||||
...narrowStyle,
|
||||
})}
|
||||
>
|
||||
{button.title}
|
||||
</ButtonWithLoading>
|
||||
)}
|
||||
</SpaceBetween>
|
||||
|
||||
{pre
|
||||
? pre.split('\n\n').map((text, idx) => (
|
||||
<View
|
||||
@@ -228,57 +333,15 @@ function Notification({
|
||||
backgroundColor: 'rgba(0, 0, 0, .05)',
|
||||
padding: 10,
|
||||
borderRadius: 4,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</View>
|
||||
))
|
||||
: null}
|
||||
{button && (
|
||||
<ButtonWithLoading
|
||||
variant="bare"
|
||||
isLoading={loading}
|
||||
onPress={async () => {
|
||||
setLoading(true);
|
||||
await button.action();
|
||||
onRemove();
|
||||
setLoading(false);
|
||||
}}
|
||||
className={css({
|
||||
backgroundColor: 'transparent',
|
||||
border: `1px solid ${
|
||||
positive
|
||||
? theme.noticeBorder
|
||||
: error
|
||||
? theme.errorBorder
|
||||
: theme.warningBorder
|
||||
}`,
|
||||
color: 'currentColor',
|
||||
...styles.mediumText,
|
||||
flexShrink: 0,
|
||||
'&[data-hovered], &[data-pressed]': {
|
||||
backgroundColor: positive
|
||||
? theme.noticeBackground
|
||||
: error
|
||||
? theme.errorBackground
|
||||
: theme.warningBackground,
|
||||
},
|
||||
...narrowStyle,
|
||||
})}
|
||||
>
|
||||
{button.title}
|
||||
</ButtonWithLoading>
|
||||
)}
|
||||
</SpaceBetween>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Close')}
|
||||
style={{ padding: 10, color: 'currentColor' }}
|
||||
onPress={onRemove}
|
||||
>
|
||||
<SvgDelete style={{ width: 10, height: 10 }} />
|
||||
</Button>
|
||||
</SpaceBetween>
|
||||
</View>
|
||||
{overlayLoading && (
|
||||
<View
|
||||
style={{
|
||||
@@ -306,6 +369,13 @@ export function Notifications({ style }: { style?: CSSProperties }) {
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const notifications = useSelector(state => state.notifications.notifications);
|
||||
const notificationInset = useSelector(state => state.notifications.inset);
|
||||
|
||||
// Only show the last N notifications (newest first) for z-axis stacking
|
||||
// Reverse so newest notification (last in array) becomes index 0 (front)
|
||||
const visibleNotifications = notifications
|
||||
.slice(-MAX_VISIBLE_NOTIFICATIONS)
|
||||
.reverse();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -318,18 +388,22 @@ export function Notifications({ style }: { style?: CSSProperties }) {
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{notifications.map(note => (
|
||||
<Notification
|
||||
key={note.id}
|
||||
notification={note}
|
||||
onRemove={() => {
|
||||
if (note.onClose) {
|
||||
note.onClose();
|
||||
}
|
||||
dispatch(removeNotification({ id: note.id }));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<View style={{ position: 'relative' }}>
|
||||
{visibleNotifications.map((note, index) => (
|
||||
<Notification
|
||||
key={note.id}
|
||||
notification={note}
|
||||
index={index}
|
||||
isInteractive={index === 0}
|
||||
onRemove={() => {
|
||||
if (note.onClose) {
|
||||
note.onClose();
|
||||
}
|
||||
dispatch(removeNotification({ id: note.id }));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||