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; }