Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type NotificationOptions = {
button?: ButtonOptions;
link?: LinkOptions;
hasTimer?: boolean;
onClose?: (() => void) | null;
};

export type InlineNotificationOptions = {
Expand All @@ -39,7 +40,9 @@ export interface NotificationElement extends HTMLElement {
timeoutID: ReturnType<typeof setTimeout> | null;
elapsed: number;
startedAt: number;
remaining: number;
anchorElement: HTMLElement;
onClose?: (() => void) | null;
}

export type NotificationService = {
Expand Down
61 changes: 28 additions & 33 deletions src/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,21 +47,15 @@ export class Notification {
button,
link,
hasTimer,
onClose,
}: NotificationOptions): void {
if (group) {
let stack = this.notificationStacks.get(position);
if (stack) {
const notificationToRemove = [...stack]
.reverse()
.find(
item =>
item.group === group &&
!item.classList.contains('z-notification--leaving'),
);
if (notificationToRemove) {
this.removeNotification(notificationToRemove);
}
}
const notificationsToRemove = this.notificationStacks
.get(position)
?.filter(item => item.group === group);
notificationsToRemove?.forEach(notification => {
this.removeNotification(notification, { shouldReflow: false });
});
}

const notification = this.createNotification({
Expand All @@ -74,6 +68,7 @@ export class Notification {
button,
link,
hasTimer,
onClose,
});

this.insertNotification(notification);
Expand Down Expand Up @@ -170,16 +165,19 @@ export class Notification {
element: HTMLElement,
group: string | null,
position: NotificationPosition,
onClose?: (() => void) | null,
): NotificationElement {
const el = document.createElement('div') as unknown as NotificationElement;
el.setAttribute('popover', 'manual');
el.group = group;
el.hasTimer = false;
el.onClose = onClose;
el.isPaused = false;
el.position = position;
el.timeoutID = null;
el.elapsed = 0;
el.startedAt = 0;
el.remaining = this.notificationTimeout;
el.anchorElement = element;
return el;
}
Expand All @@ -194,8 +192,9 @@ export class Notification {
button,
link,
hasTimer,
onClose = null,
}: NotificationOptions): NotificationElement {
const notification = this.createNotificationElement(element, group, position);
const notification = this.createNotificationElement(element, group, position, onClose);
notification.className = `z-notification z-notification--${position} z-notification--${status}`;

const buttonClass = 'z-notification__action-btn';
Expand Down Expand Up @@ -228,6 +227,7 @@ export class Notification {
);
closeButton.onclick = () => {
this.setFocus(notification.anchorElement);
notification.remaining = 0;
this.removeNotification(notification);
};
}
Expand All @@ -252,12 +252,8 @@ export class Notification {
addNotificationToStack(notification: NotificationElement): void {
const stack = this.getStack(notification.position);
stack.push(notification);

const activeNotifications = stack.filter(
item => !item.classList.contains('z-notification--leaving'),
);
if (activeNotifications.length > MAX_NOTIFICATIONS_PER_POSITION) {
this.removeNotification(activeNotifications[0], { shouldReflow: false });
if (stack.length > MAX_NOTIFICATIONS_PER_POSITION) {
this.removeNotification(stack[0], { shouldReflow: false });
}
}

Expand Down Expand Up @@ -375,6 +371,7 @@ export class Notification {
notification.startedAt = Date.now();
notification.timeoutID = setTimeout(() => {
if (!notification.isPaused) {
notification.remaining = 0;
this.setFocus(notification.anchorElement);
this.removeNotification(notification);
}
Expand All @@ -400,13 +397,13 @@ export class Notification {
const resume = () => {
notification.isPaused = false;
notification.startedAt = Date.now();
const remaining = this.notificationTimeout - notification.elapsed;
if (remaining <= 0) {
notification.remaining = this.notificationTimeout - notification.elapsed;
if (notification.remaining <= 0) {
this.setFocus(notification.anchorElement);
this.removeNotification(notification);
return;
}
this.startTimeout(notification, remaining);
this.startTimeout(notification, notification.remaining);
if (ring) {
ring.style.animationPlayState = 'running';
}
Expand All @@ -425,8 +422,12 @@ export class Notification {
if (notification.timeoutID) {
clearTimeout(notification.timeoutID);
}

this.dispatchEvent('notification-removed', notification.anchorElement);
// when a grouped notification is removed, it can have some time remaining
// this happens, when the next notification of the same group appears.
// onClose won't get called then.
if (notification.remaining <= 0 && notification.onClose) {
notification.onClose();
}
this.finishRemovingNotification(notification, { shouldReflow });
}

Expand Down Expand Up @@ -472,14 +473,6 @@ export class Notification {
}
}

dispatchEvent(eventName: string, anchor: HTMLElement | null): void {
document.dispatchEvent(
new CustomEvent(eventName, {
detail: { originator: anchor },
}),
);
}

removeInlineNotification(container: HTMLElement, inline: InlineNotification): void {
if (inline.timeoutID) {
clearTimeout(inline.timeoutID);
Expand Down Expand Up @@ -507,6 +500,7 @@ const notification: NotificationService = {
button,
link,
hasTimer,
onClose,
}: NotificationOptions): void {
this.notification.show({
group,
Expand All @@ -518,6 +512,7 @@ const notification: NotificationService = {
button,
link,
hasTimer,
onClose,
});
},
debug(): void {
Expand Down
67 changes: 67 additions & 0 deletions src/tests/notification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,4 +674,71 @@ describe('notification accessibility behavior', () => {
expect(screen.getByText('Foo notification')).not.toBeNull();
expect(screen.getByText('Share notification')).not.toBeNull();
});

it('calls onClose callback when timer expires', async () => {
const onCloseMock = vi.fn();

notification.show({
message: 'Notification with onClose',
status: 'info',
hasTimer: true,
onClose: onCloseMock,
});

expect(onCloseMock).not.toHaveBeenCalled();

await vi.advanceTimersByTimeAsync(notification.notificationTimeout);

expect(onCloseMock).toHaveBeenCalledTimes(1);
});

it('calls onClose callback when closed manually', async () => {
const onCloseMock = vi.fn();

const user = userEvent.setup({
advanceTimers: vi.advanceTimersByTime,
});

notification.show({
message: 'Notification with onClose manual',
status: 'info',
hasTimer: true,
onClose: onCloseMock,
});

await user.click(screen.getByRole('button', { name: 'Meldung schließen', hidden: true }));

expect(onCloseMock).toHaveBeenCalledTimes(1);
});

it('does not call onClose when notification is replaced by same group', async () => {
const onCloseMock = vi.fn();

notification.show({
group: 'test-group',
message: 'First notification',
status: 'info',
hasTimer: true,
onClose: onCloseMock,
});

await vi.advanceTimersByTimeAsync(1000);

notification.show({
group: 'test-group',
message: 'Second notification',
status: 'info',
hasTimer: true,
});

// First notification was replaced, onClose should not have been called
expect(onCloseMock).not.toHaveBeenCalled();
expect(screen.queryByText('First notification')).toBeNull();
expect(screen.getByText('Second notification')).not.toBeNull();

await vi.advanceTimersByTimeAsync(notification.notificationTimeout);

// Still not called since first was replaced
expect(onCloseMock).not.toHaveBeenCalled();
});
});
Loading