diff --git a/src/components/alert/bl-alert.test.ts b/src/components/alert/bl-alert.test.ts
index 064bf2fd4..67bf58d3c 100644
--- a/src/components/alert/bl-alert.test.ts
+++ b/src/components/alert/bl-alert.test.ts
@@ -161,6 +161,7 @@ describe("Slot", () => {
expect(el.outerHTML).to.eq('');
});
+ it.todo("renders action slots from fallthrough slots");
});
describe("Events", () => {
diff --git a/src/components/alert/bl-alert.ts b/src/components/alert/bl-alert.ts
index c176181df..fd4cfb451 100644
--- a/src/components/alert/bl-alert.ts
+++ b/src/components/alert/bl-alert.ts
@@ -108,9 +108,13 @@ export default class BlAlert extends LitElement {
// Allow fallthrough slots. ref: bl-notification-card.ts
if (slotElements.some(element => element.tagName === "SLOT")) {
- slotElements = slotElements.flatMap(element =>
- (element as HTMLSlotElement).assignedElements()
- );
+ slotElements = slotElements.flatMap(element => {
+ if (element.tagName === "SLOT") {
+ return (element as HTMLSlotElement).assignedElements();
+ }
+
+ return element;
+ });
}
slotElements.forEach(element => {
diff --git a/src/components/notification/bl-notification.test.ts b/src/components/notification/bl-notification.test.ts
index e8e55a9f5..d3f580af9 100644
--- a/src/components/notification/bl-notification.test.ts
+++ b/src/components/notification/bl-notification.test.ts
@@ -1,7 +1,39 @@
-import { assert, fixture, html } from "@open-wc/testing";
+import { assert, expect, fixture, html } from "@open-wc/testing";
+import { setViewport, emulateMedia } from "@web/test-runner-commands";
+import { spy, stub } from "sinon";
import BlNotification from "./bl-notification";
+import BlNotificationCard from "./card/bl-notification-card";
-describe.skip("bl-notification", () => {
+function sendTouchEvent(
+ x: number,
+ y: number,
+ element: BlNotificationCard,
+ eventType: "touchstart" | "touchend" | "touchmove"
+) {
+ const touchObj = new Touch({
+ identifier: Date.now(),
+ target: element,
+ clientX: x,
+ clientY: y,
+ radiusX: 2.5,
+ radiusY: 2.5,
+ rotationAngle: 10,
+ force: 0.5,
+ });
+
+ const touchEvent = new TouchEvent(eventType, {
+ cancelable: true,
+ bubbles: true,
+ touches: [touchObj],
+ targetTouches: [],
+ changedTouches: [touchObj],
+ shiftKey: true,
+ });
+
+ element.dispatchEvent(touchEvent);
+}
+
+describe("bl-notification", () => {
it("is defined", () => {
const el = document.createElement("bl-notification");
@@ -16,29 +48,282 @@ describe.skip("bl-notification", () => {
assert.equal(style.maxWidth, "396px");
});
- describe("Default props", () => {});
+ describe("Default props", () => {
+ it("should create notifications with given default duration", async () => {
+ const el = await fixture(
+ html``
+ );
+
+ const notification = el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ icon: "academy",
+ });
+
+ await el.updateComplete;
+
+ assert.equal(notification.duration, el.duration);
- describe("Remove Notification", () => {});
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
- describe("Mobile", () => {
- beforeEach(() => {
- window.resizeTo(480, 640);
+ expect(notificationEl).to.have.attribute("duration", el.duration.toString());
});
+ });
- describe("Touch", () => {});
- describe("Animation", () => {});
+ describe("Remove Notification", () => {
+ it("should remove notification after remove animation", async () => {
+ const el = await fixture(html``);
- it("should render first notification first", async () => {
+ const notification = el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ icon: "academy",
+ });
+
+ await el.updateComplete;
+
+ const animationPromise = el.removeNotification(notification.id);
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
+
+ expect(notificationEl)
+ .to.have.attribute("style")
+ .match(/height: \d+px/);
+ assert.isTrue(notificationEl.classList.contains("removing"));
+ assert.lengthOf(el.notifications, 1);
+
+ await animationPromise;
+ await el.updateComplete;
+ assert.lengthOf(el.notifications, 0);
+ });
+
+ it("should return false if notification does not exist", async () => {
const el = await fixture(html``);
- const wrapper = el.shadowRoot!.querySelector(".wrapper")!;
- const style = window.getComputedStyle(wrapper);
+ const result = await el.removeNotification("test");
- assert.equal(style.flexDirection, "column");
+ assert.isFalse(result);
+ });
+
+ it("should call remove notification when bl-notification-card-request-close event is dispatched", async () => {
+ const el = await fixture(html``);
+
+ el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ icon: "academy",
+ });
+
+ await el.updateComplete;
+
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
+
+ notificationEl.dispatchEvent(
+ new CustomEvent("bl-notification-card-request-close", {
+ bubbles: true,
+ composed: true,
+ detail: { source: "test" },
+ })
+ );
+
+ await el.updateComplete;
+
+ assert.isTrue(notificationEl.classList.contains("removing"));
+ });
+ });
+
+ describe("Animation", () => {
+ it("should render bl-notification-card with animation", async () => {
+ const el = await fixture(html``);
+
+ el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ duration: 5,
+ icon: "academy",
+ });
+
+ await el.updateComplete;
+
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
+ const style = window.getComputedStyle(notificationEl);
+
+ expect(style.animationName).to.equal("slide-in-right");
+ });
+
+ it("should render out bl-notification-card with animation", async () => {
+ const el = await fixture(html``);
+
+ const notification = el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ duration: 5,
+ icon: "academy",
+ });
+
+ await el.updateComplete;
+
+ el.removeNotification(notification.id);
+
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
+ const style = window.getComputedStyle(notificationEl);
+
+ expect(style.animationName).to.equal("slide-out-right, size-to-zero");
+ });
+
+ it("should not run animations when user has preferred reduced motion", async () => {
+ await emulateMedia({
+ reducedMotion: "reduce",
+ });
+
+ const el = await fixture(html``);
+
+ const { remove } = el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ duration: 5,
+ icon: "academy",
+ });
+
+ await el.updateComplete;
+
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
+
+ assert.equal(window.getComputedStyle(notificationEl).animationName, "none");
+
+ remove();
+
+ assert.equal(window.getComputedStyle(notificationEl).animationName, "size-to-zero");
+
+ await emulateMedia({
+ reducedMotion: "no-preference",
+ });
});
});
- describe("Animation", () => {});
+ describe("Actions", () => {
+ it('should render action slot when provided with "action" property on notification', async () => {
+ const el = await fixture(html``);
+
+ el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ duration: 5,
+ icon: "academy",
+ action: {
+ label: "test",
+ onClick: () => {},
+ },
+ });
+
+ await el.updateComplete;
+
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
+
+ assert.exists(notificationEl.querySelector("[slot=action]"));
+ assert.include(notificationEl.querySelector("[slot=action]")?.textContent, "test");
+ });
+
+ it("should call onClick callback when action is clicked", async () => {
+ const onClick = stub();
+
+ const el = await fixture(html``);
+
+ el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ duration: 5,
+ icon: "academy",
+ action: {
+ label: "test",
+ onClick,
+ },
+ });
+
+ await el.updateComplete;
+
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
+
+ notificationEl.querySelector("[slot=action]")?.dispatchEvent(
+ new CustomEvent("bl-click", {
+ bubbles: true,
+ composed: true,
+ })
+ );
+
+ await el.updateComplete;
+
+ expect(onClick).to.have.been.calledOnce;
+ });
+
+ describe("Secondary Actions", () => {
+ it('should render secondary action slot when provided with "actionSecondary" property on notification', async () => {
+ const el = await fixture(html``);
+
+ el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ duration: 5,
+ icon: "academy",
+ actionSecondary: {
+ label: "test",
+ onClick: () => {},
+ },
+ });
+
+ await el.updateComplete;
+
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
+
+ assert.exists(notificationEl.querySelector("[slot=action-secondary]"));
+ assert.include(
+ notificationEl.querySelector("[slot=action-secondary]")?.textContent,
+ "test"
+ );
+ });
+
+ it("should call onClick callback when secondary action is clicked", async () => {
+ const onClick = stub();
+
+ const el = await fixture(html``);
+
+ el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ duration: 5,
+ icon: "academy",
+ actionSecondary: {
+ label: "test",
+ onClick,
+ },
+ });
+
+ await el.updateComplete;
+
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
+
+ notificationEl.querySelector("[slot=action-secondary]")?.dispatchEvent(
+ new CustomEvent("bl-click", {
+ bubbles: true,
+ composed: true,
+ })
+ );
+
+ await el.updateComplete;
+
+ expect(onClick).to.have.been.calledOnce;
+ });
+ });
+ });
describe("Add Notification", () => {
it("should render bl-notification-card to match snapshot", async () => {
@@ -82,5 +367,274 @@ describe.skip("bl-notification", () => {
assert.equal(style.flexDirection, "column-reverse");
});
+
+ describe("Notification Interface", () => {
+ it("should return notification with given id", async () => {
+ const el = await fixture(html``);
+
+ const notification = el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ duration: 5,
+ icon: "academy",
+ });
+
+ await el.updateComplete;
+
+ assert.equal(el.notifications[0], notification);
+ });
+
+ it("should return functional remove method", async () => {
+ const el = await fixture(html``);
+
+ const notification = el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ duration: 5,
+ icon: "academy",
+ });
+
+ await el.updateComplete;
+
+ assert.isFunction(notification.remove);
+
+ await notification.remove();
+
+ await el.updateComplete;
+
+ assert.lengthOf(el.notifications, 0);
+ });
+ });
+ });
+
+ describe("Mobile", () => {
+ beforeEach(async () => {
+ await setViewport({ width: 320, height: 480 });
+ });
+
+ describe("Touch", () => {
+ it("should save touch start position", async () => {
+ // FIXME: Cant emulate touch events in web test runner
+ if (!window.navigator.userAgent.includes("Chrome")) {
+ return;
+ }
+
+ const el = await fixture(html``);
+
+ el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ duration: 5,
+ icon: "academy",
+ });
+
+ await el.updateComplete;
+
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
+
+ sendTouchEvent(100, 100, notificationEl, "touchstart");
+
+ assert.equal(el.touchStart.y, 100);
+ });
+
+ it("should remove notification when user swipes up", async () => {
+ // FIXME: Cant emulate touch events in web test runner
+ if (!window.navigator.userAgent.includes("Chrome")) {
+ return;
+ }
+
+ const el = await fixture(html``);
+ const removeSpy = spy(el, "removeNotification");
+
+ el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ duration: 5,
+ icon: "academy",
+ });
+
+ await el.updateComplete;
+
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
+
+ sendTouchEvent(100, -100, notificationEl, "touchend");
+
+ expect(removeSpy).to.have.been.calledOnceWith(el.notifications[0].id);
+ });
+
+ it("should not remove notification when user swipes up less than 50px", async () => {
+ // FIXME: Cant emulate touch events in web test runner
+ if (!window.navigator.userAgent.includes("Chrome")) {
+ return;
+ }
+
+ const el = await fixture(html``);
+ const removeSpy = spy(el, "removeNotification");
+
+ el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ duration: 5,
+ icon: "academy",
+ });
+
+ await el.updateComplete;
+
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
+
+ sendTouchEvent(0, -49, notificationEl, "touchend");
+
+ await el.updateComplete;
+
+ expect(removeSpy).to.not.have.been.called;
+ expect(notificationEl.style.transform).to.equal("");
+ });
+
+ it("should update transform style when users touch moves up or down", async () => {
+ // FIXME: Cant emulate touch events in web test runner
+ if (!window.navigator.userAgent.includes("Chrome")) {
+ return;
+ }
+
+ const el = await fixture(html``);
+
+ el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ duration: 5,
+ icon: "academy",
+ });
+
+ await el.updateComplete;
+
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
+
+ sendTouchEvent(100, 50, notificationEl, "touchmove");
+
+ await el.updateComplete;
+
+ assert.equal(notificationEl.style.transform, "translateY(50px)");
+
+ sendTouchEvent(100, -50, notificationEl, "touchmove");
+
+ await el.updateComplete;
+
+ assert.equal(notificationEl.style.transform, "translateY(-50px)");
+ });
+
+ it("should do nothing if device is not mobile", async () => {
+ // FIXME: Cant emulate touch events in web test runner
+ if (!window.navigator.userAgent.includes("Chrome")) {
+ return;
+ }
+
+ await setViewport({ width: 1024, height: 768 });
+
+ const el = await fixture(html``);
+ const removeSpy = spy(el, "removeNotification");
+
+ el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ duration: 5,
+ icon: "academy",
+ });
+
+ await el.updateComplete;
+
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
+
+ sendTouchEvent(100, 100, notificationEl, "touchstart");
+ await el.updateComplete;
+
+ assert.equal(el.touchStart.y, 0);
+
+ sendTouchEvent(100, 50, notificationEl, "touchmove");
+ await el.updateComplete;
+
+ assert.equal(notificationEl.style.transform, "");
+
+ sendTouchEvent(100, 50, notificationEl, "touchend");
+ await el.updateComplete;
+
+ expect(removeSpy).to.not.have.been.called;
+ });
+ });
+
+ describe("Animation", () => {
+ it("should render bl-notification-card with animation from top", async () => {
+ const el = await fixture(html``);
+
+ el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ duration: 5,
+ icon: "academy",
+ });
+
+ await el.updateComplete;
+
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
+ const style = window.getComputedStyle(notificationEl);
+
+ expect(style.animationName).to.equal("slide-in-top");
+ });
+
+ it("should render out bl-notification-card with animation to top", async () => {
+ const el = await fixture(html``);
+
+ const notification = el.addNotification({
+ caption: "test",
+ description: "test",
+ variant: "info",
+ duration: 5,
+ icon: "academy",
+ });
+
+ await el.updateComplete;
+
+ el.removeNotification(notification.id);
+
+ const notificationEl = el.shadowRoot!.querySelector("bl-notification-card")!;
+ const style = window.getComputedStyle(notificationEl);
+
+ expect(style.animationName).to.equal("slide-out-top, size-to-zero");
+ });
+ });
+
+ it("should have fixed width of 100%", async () => {
+ // FIXME: Safari won't update styles.
+ if (!window.navigator.userAgent.includes("Chrome")) {
+ return;
+ }
+
+ const el = await fixture(html``);
+ const wrapper = el.shadowRoot!.querySelector(".wrapper")!;
+ const style = window.getComputedStyle(wrapper);
+
+ assert.equal(style.maxWidth, "100%");
+ });
+
+ it("should render first notification first", async () => {
+ // FIXME: Safari won't update styles.
+ if (!window.navigator.userAgent.includes("Chrome")) {
+ return;
+ }
+
+ const el = await fixture(html``);
+
+ const wrapper = el.shadowRoot!.querySelector(".wrapper")!;
+ const style = window.getComputedStyle(wrapper);
+
+ assert.equal(style.flexDirection, "column");
+ });
});
});
diff --git a/src/components/notification/bl-notification.ts b/src/components/notification/bl-notification.ts
index fdcdc918b..b271bf147 100644
--- a/src/components/notification/bl-notification.ts
+++ b/src/components/notification/bl-notification.ts
@@ -18,7 +18,8 @@ export type NotificationProps = {
description: string;
icon?: boolean | BaklavaIcon;
variant?: AlertVariant;
- action?: Action & { secondary: Action };
+ action?: Action;
+ actionSecondary?: Action;
duration?: number;
permanent?: boolean;
};
@@ -60,6 +61,12 @@ export default class BlNotification extends LitElement {
private touchStartY = 0;
+ public get touchStart() {
+ return {
+ y: this.touchStartY,
+ };
+ }
+
private get isMobile() {
return window.matchMedia("(max-width: 480px)").matches;
}
@@ -73,6 +80,7 @@ export default class BlNotification extends LitElement {
// TODO id generation
const id = Math.random().toString(36).substr(2, 9);
const notification: Notification = {
+ duration: this.duration,
...props,
id,
remove: () => this.removeNotification(id),
@@ -160,11 +168,9 @@ export default class BlNotification extends LitElement {
this.touchStartY = 0;
}
- private renderActionSlot(
- action: Action | undefined,
- slotName: "action" | "action-secondary",
- notification: Notification
- ) {
+ private renderActionSlot(slotName: "action" | "action-secondary", notification: Notification) {
+ const action = slotName === "action" ? notification.action : notification.actionSecondary;
+
if (!action || !action.label) {
return "";
}
@@ -189,15 +195,10 @@ export default class BlNotification extends LitElement {
id,
duration = this.duration,
permanent,
- action,
} = notification;
- const actionButton = this.renderActionSlot(action, "action", notification);
- const secondaryActionButton = this.renderActionSlot(
- action?.secondary,
- "action-secondary",
- notification
- );
+ const actionButton = this.renderActionSlot("action", notification);
+ const secondaryActionButton = this.renderActionSlot("action-secondary", notification);
return html`
{
+ setTimeout(() => {
this.shadowRoot?.querySelector(".remaining")?.addEventListener(
"animationend",
() => {
@@ -115,13 +115,12 @@ export default class BlNotificationCard extends LitElement {
},
{ once: true }
);
- });
+ }, 0);
}
private close(source: CloseSource) {
const requestCloseEvent = this.onRequestClose({ source }, { cancelable: true });
- console.log(requestCloseEvent);
if (requestCloseEvent.defaultPrevented) {
return;
}