diff --git a/index.html b/index.html index 739673d..30931da 100644 --- a/index.html +++ b/index.html @@ -24,9 +24,20 @@

Craft your theme

- + + + Reset Your Workspace? + + Are you sure you want to reset your workspace? All unsaved changes will be lost and the + theme will revert to the default settings. This action cannot be undone. + + Confirm Reset + diff --git a/package.json b/package.json index ab2e065..81b340f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "prebuild": "node .prebuild.js", "build": "vite build", - "test": "vitest run --coverage --reporter=verbose --silent", + "test": "npm run prebuild && vitest run --coverage --reporter=verbose --silent", "eslint": "eslint {src,test}/**/*.js", "eslint:fix": "eslint {src,test}/**/*.js --fix", "outdated": "npm outdated", diff --git a/scss/index.scss b/scss/index.scss index c2191ed..834b8b0 100644 --- a/scss/index.scss +++ b/scss/index.scss @@ -107,9 +107,9 @@ footer { height: 100%; } -#download { +.pbte-btn, .pbte-alert-btn { padding: 1em; - color: var(--button-foreground); + color: var(--button-color); background: var(--button-background); border: none; border-radius: var(--button-border-radius); @@ -117,10 +117,18 @@ footer { transition: background-color 0.3s ease; } -#download:hover { +.pbte-btn:hover { background-color: var(--button-hover-background); } +.pbte-btn.alert { + background: #9D4040; +} + +.pbte-btn.alert:hover { + background: #733030; +} + input { margin: 0; padding: 0.5em 0.2em; @@ -131,6 +139,10 @@ input { outline: none; } +confirmation-dialog { + max-width: 360px; +} + *:not(:defined) { display: none; } diff --git a/src/components/confirmation-dialog.js b/src/components/confirmation-dialog.js new file mode 100644 index 0000000..f1efff7 --- /dev/null +++ b/src/components/confirmation-dialog.js @@ -0,0 +1,141 @@ +import { html, LitElement, unsafeCSS } from 'lit'; +import { createRef, ref } from 'lit/directives/ref.js'; +import confirmationDialogStyle from './confirmation-dialog.scss?inline'; + +/** + * `ConfirmationDialog` is a LitElement that renders a modal dialog window which + * can be used to confirm or reject an action. It encapsulates user interaction + * in the form of "accept" or "cancel" actions. + * + * @fires ConfirmationDialog#close Fired when the dialog is closed, with `detail.accepted` indicating if the "accept" was clicked. + * @fires ConfirmationDialog#open Fired when the dialog is opened. + * + * @part body - The container of the dialog's content. + * @part actions - The container of the dialog's action buttons. + * @part button - Parts for styling the buttons individually. + * @part button cancel - Part for styling the cancel button. + * @part button accept - Part for styling the accept button. + * + * @slot title - The dialog's title. Provides context about the dialog's purpose. + * @slot content - The main content of the dialog, such as descriptions or questions. + * @slot cancel - (Optional) Customizes the cancel button text. Defaults to "Cancel." + * @slot accept - (Optional) Customizes the accept button text. Defaults to "Accept." + * + * @cssproperty [--dialog-padding=1em] - The padding inside the dialog. + * @cssproperty [--dialog-background-color=#242424] - The background color of the dialog. + * @cssproperty [--dialog-border=1px solid #666] - The border style for the dialog. + * @cssproperty [--dialog-color=rgb(255 255 255 / 87%)] - The text color inside the dialog. + * @cssproperty [--dialog-title-font-weight=600] - The font weight for the dialog title. + * @cssproperty [--dialog-border-radius=0.5em] - The border radius of the dialog. + * @cssproperty [--dialog-max-width=360px] - The maximum width of the dialog. + * @cssproperty [--button-color=rgb(255 255 255 / 87%)] - The text color of the buttons. + * @cssproperty [--button-border-radius=0.5em] - The border radius of the buttons. + * @cssproperty [--accept-button-background=#9D4040] - The background color of the accept button. + * @cssproperty [--accept-button-hover-background=#733030] - The background color of the accept button on hover. + * @cssproperty [--cancel-button-background=rgb(255 255 255 / 0%)] - The background color of the cancel button. + * @cssproperty [--cancel-button-hover-background=rgb(255 255 255 / 20%)] - The background color of the cancel button on hover. + * @cssproperty [--cancel-button-hover-color=rgb(0 0 0 / 87%)] - The text color of the cancel button on hover. + * + * @example + * + * Are you sure ? + * This action is irreversible + * + */ +class ConfirmationDialog extends LitElement { + static styles = unsafeCSS(confirmationDialogStyle); + + static properties = { + open: { + type: Boolean, + state: true + }, + accepted: { + type: Boolean, + state: true + } + }; + + /** + * Reference to the dialog HTML element. + * + * @type {import('lit/directives/ref').Ref} + * @private + */ + #dialog = createRef(); + + constructor() { + super(); + this.open = false; + this.accepted = false; + } + + render() { + return html` + +
+ + +
+ + +
+
+
+ `; + } + + #close(accepted = false) { + this.open = false; + this.accepted = accepted; + } + + updated(_changedProperties) { + super.updated(_changedProperties); + + if (_changedProperties.has('open')) { + this.#updateDialog(); + } + } + + #updateDialog() { + if (this.open) { + this.#dialog.value.showModal(); + /** + * Custom event dispatched when the dialog is opened. + * + * @event ConfirmationDialog#open + * @type {CustomEvent} + */ + this.dispatchEvent(new CustomEvent('open')); + } else { + this.#dialog.value.close(); + /** + * Custom event dispatched when the dialog is closed. Includes whether the closure was an acceptance. + * + * @event ConfirmationDialog#close + * @type {CustomEvent} + * @property {Object} detail - The event detail object. + * @property {boolean} detail.accepted - Indicates if the dialog was closed with an acceptance. + */ + this.dispatchEvent(new CustomEvent('close', { detail: { accepted: this.accepted } })); + } + this.accepted = false; + } + + /** + * Toggles the dialog's open state. + * + * @param {boolean} [open] Specifies the desired open state. If not provided, + * it toggles the current state. + */ + toggle(open) { + this.open = open ?? !this.open; + } +} + +customElements.define('confirmation-dialog', ConfirmationDialog); diff --git a/src/components/confirmation-dialog.scss b/src/components/confirmation-dialog.scss new file mode 100644 index 0000000..ba8c6f9 --- /dev/null +++ b/src/components/confirmation-dialog.scss @@ -0,0 +1,79 @@ +:host { + --dialog-padding: 1em; + --dialog-background-color: #242424; + --dialog-border: 1px solid #666; + --dialog-color: rgb(255 255 255 / 87%); + --dialog-title-font-weight: 600; + --dialog-border-radius: 0.5em; + --dialog-max-width: 360px; + --accept-button-background: #9D4040; + --accept-button-hover-background: #733030; + --cancel-button-background: rgb(255 255 255 / 0%); + --cancel-button-hover-background: rgb(255 255 255 / 20%); + --cancel-button-hover-color: rgb(0 0 0 / 87%); + --button-color: rgb(255 255 255 / 87%); + --button-border-radius: 0.5em; +} + +dialog { + max-width: var(--dialog-max-width); + padding: var(--dialog-padding); + overflow: visible; + color: var(--dialog-color); + background-color: var(--dialog-background-color); + border: var(--dialog-border); + border-radius: var(--dialog-border-radius); +} + +dialog::backdrop { + background: rgb(0 0 0 / 50%); +} + +::slotted([slot="title"]) { + font-weight: var(--dialog-title-font-weight); +} + +::slotted([slot="content"]) { + text-align: justify; +} + +[part="body"] { + display: flex; + flex-direction: column; + gap: 0.5em; +} + +[part="actions"] { + display: flex; + gap: 0.5em; + justify-content: right; +} + +[part~="button"] { + padding: 1em; + color: var(--button-color); + background-color: transparent; + border: none; + border-radius: var(--button-border-radius); + cursor: pointer; + transition-timing-function: ease; + transition-duration: 0.3s; + transition-property: color, background-color; +} + +[part="button cancel"] { + background: var(--cancel-button-background); +} + +[part="button cancel"]:hover { + color: var(--cancel-button-hover-color); + background-color: var(--cancel-button-hover-background); +} + +[part="button accept"] { + background: var(--accept-button-background); +} + +[part="button accept"]:hover { + background-color: var(--accept-button-hover-background); +} diff --git a/src/components/toggle-pane-button.scss b/src/components/toggle-pane-button.scss index 035b6a7..734396c 100644 --- a/src/components/toggle-pane-button.scss +++ b/src/components/toggle-pane-button.scss @@ -12,7 +12,7 @@ [part="button"] { position: relative; /* Needed for the chevron */ padding: 1em; - color: var(--button-foreground); + color: var(--button-color); background: var(--button-background); border: none; border-radius: var(--button-border-radius); diff --git a/src/index.js b/src/index.js index 288b304..1fbd22f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,23 +1,42 @@ -import './components/css-editor.js'; import './components/resizable-split-view.js'; -import './components/preview-box.js'; import './components/tree-view.js'; import './components/toggle-pane-button.js'; +import './components/confirmation-dialog.js'; +import './components/preview-box.js'; +import './components/css-editor.js'; import sassCompiler from './workspace/workspace.js'; +import WorkspaceProvider from './workspace/workspace-provider.js'; + +// Preview Initialisation +const preview = document.getElementById('preview'); +const sourceInput = document.getElementById('src-input'); + +sourceInput.addEventListener('keyup', (event) => { + const src = event.target.value; + if (event.key === 'Enter' && src) { + preview.mediaSrc = src; + } +}); + +preview.appliedCss = sassCompiler.compile(); + +// Editor Initialisation +const editor = document.getElementById('editor'); let currentItem = sassCompiler.mainScss; +editor.setValue(sassCompiler.mainScss.content); +editor.addEventListener('value-changed', (event) => { + currentItem.content = event.detail.value; + preview.appliedCss = sassCompiler.compile(); + WorkspaceProvider.saveWorkspace(sassCompiler.workspace); +}); + +// Navigation Control const navigation = document.getElementById('navigation'); const navigationButton = document.getElementById('navigation-button'); -const editor = document.getElementById('editor'); -const preview = document.getElementById('preview'); -const downloadButton = document.getElementById('download'); -const sourceInput = document.getElementById('src-input'); navigation.items = sassCompiler.workspace; -navigationButton.label = currentItem.name; -editor.setValue(currentItem.content); - navigation.addEventListener('selected', (event) => { currentItem = event.detail; navigationButton.label = currentItem.name; @@ -25,18 +44,23 @@ navigation.addEventListener('selected', (event) => { navigationButton.opened = false; }); -preview.appliedCss = sassCompiler.compile(); -editor.addEventListener('value-changed', (event) => { - currentItem.content = event.detail.value; - preview.appliedCss = sassCompiler.compile(); -}); +navigationButton.label = sassCompiler.mainScss.name; + +// Download Control +const downloadButton = document.getElementById('download-button'); downloadButton.addEventListener('click', () => sassCompiler.download()); -sourceInput.addEventListener('keyup', (event) => { - const src = event.target.value; +// Reset Control +const resetButton = document.getElementById('reset-button'); +const confirmationDialog = document.getElementById('reset-confirmation-dialog'); - if (event.key === 'Enter' && src) { - preview.mediaSrc = src; +resetButton.addEventListener('click', () => { + confirmationDialog.toggle(); +}); +confirmationDialog.addEventListener('close', (event) => { + if (event.detail.accepted) { + WorkspaceProvider.clear(); + window.location.reload(); } }); diff --git a/src/workspace/workspace-provider.js b/src/workspace/workspace-provider.js new file mode 100644 index 0000000..a58f7ce --- /dev/null +++ b/src/workspace/workspace-provider.js @@ -0,0 +1,38 @@ +import pillarboxScssWorkspace from '../assets/pillarbox-scss-workspace.json'; + +/** + * Utility class for loading and saving the workspace to local storage. + * + * @class WorkspaceProvider + */ +class WorkspaceProvider { + /** + * Loads the workspace from local storage. + * + * @static + * @returns {Object} An object representing the workspace. + */ + static loadWorkspace() { + return JSON.parse(localStorage.getItem('pbte-workspace')) || pillarboxScssWorkspace; + } + + /** + * Save the workspace to local storage. + * + * @static + * @param {Object} workspace An object representing the workspace. to be saved. + * @returns {void} + */ + static saveWorkspace(workspace) { + localStorage.setItem('pbte-workspace', JSON.stringify(workspace)); + } + + /** + * Clears the workspace from the local storage. + */ + static clear() { + localStorage.removeItem('pbte-workspace'); + } +} + +export default WorkspaceProvider; diff --git a/src/workspace/workspace.js b/src/workspace/workspace.js index d6d92fc..5ef573a 100644 --- a/src/workspace/workspace.js +++ b/src/workspace/workspace.js @@ -1,6 +1,6 @@ -import pillarboxScssWorkspace from '../assets/pillarbox-scss-workspace.json'; import videoJsStyle from 'video.js/dist/video-js.css?inline'; import { SassWorkspaceCompiler } from './sass-workspace-compiler.js'; +import WorkspaceProvider from './workspace-provider.js'; /** * Instance of `SassWorkspaceCompiler` dedicated to compiling the sass styles for pillarbox @@ -11,7 +11,7 @@ import { SassWorkspaceCompiler } from './sass-workspace-compiler.js'; * @see SassWorkspaceCompiler */ export default new SassWorkspaceCompiler( - pillarboxScssWorkspace, + WorkspaceProvider.loadWorkspace(), 'pillarbox.scss', { '../node_modules/video.js/dist/video-js': { diff --git a/test/components/confirmation-dialog.test.js b/test/components/confirmation-dialog.test.js new file mode 100644 index 0000000..9987585 --- /dev/null +++ b/test/components/confirmation-dialog.test.js @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, beforeAll, describe, expect, test, vi } from 'vitest'; +import '../../src/components/confirmation-dialog.js'; + +describe('ConfirmationDialog Component', () => { + let element; + + beforeAll(() => { + HTMLDialogElement.prototype.show = vi.fn(); + HTMLDialogElement.prototype.showModal = vi.fn(); + HTMLDialogElement.prototype.close = vi.fn(); + }); + + beforeEach(async() => { + element = document.createElement('confirmation-dialog'); + document.body.appendChild(element); + await element.updateComplete; + }); + + afterEach(() => { + document.body.removeChild(element); + }); + + test('dispatches an "open" event when the dialog is opened', async() => { + const openSpy = vi.fn(); + + element.addEventListener('open', openSpy); + + element.toggle(true); + await element.updateComplete; + expect(openSpy).toHaveBeenCalledTimes(1); + }); + + test('dispatches a "close" event with details when the dialog is closed by acceptance', async() => { + const closeSpy = vi.fn(); + + element.addEventListener('close', closeSpy); + + element.toggle(true); + await element.updateComplete; + + // Simulate clicking the 'Accept' button + element.shadowRoot.querySelector('button[part="button accept"]').click(); + await element.updateComplete; + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledWith(expect.objectContaining({ + detail: { accepted: true } + })); + }); + + test('dispatches a "close" event with details when the dialog is closed by cancelling', async() => { + const closeSpy = vi.fn(); + + element.addEventListener('close', closeSpy); + + element.toggle(true); + await element.updateComplete; + + // Simulate clicking the 'Cancel' button + element.shadowRoot.querySelector('button[part="button cancel"]').click(); + await element.updateComplete; + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledWith(expect.objectContaining({ + detail: { accepted: false } + })); + }); + + test('dispatches a "close" event with details when the dialog is closed programmatically', async() => { + const closeSpy = vi.fn(); + + element.addEventListener('close', closeSpy); + + element.toggle(true); + await element.updateComplete; + + element.toggle(false); + await element.updateComplete; + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledWith(expect.objectContaining({ + detail: { accepted: false } + })); + }); +}); diff --git a/test/workspace/workspace-provider.test.js b/test/workspace/workspace-provider.test.js new file mode 100644 index 0000000..328da9a --- /dev/null +++ b/test/workspace/workspace-provider.test.js @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import WorkspaceProvider from '../../src/workspace/workspace-provider.js'; +import pillarboxScssWorkspace from '../../src/assets/pillarbox-scss-workspace.json'; + +describe('WorkspaceProvider', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('loads the default workspace when local storage is empty', () => { + expect(WorkspaceProvider.loadWorkspace()).toEqual(pillarboxScssWorkspace); + }); + + it('loads the workspace correctly from local storage', () => { + const mockWorkspace = { + id: 1, + setting: 'test' + }; + + localStorage.setItem('pbte-workspace', JSON.stringify(mockWorkspace)); + expect(WorkspaceProvider.loadWorkspace()).toEqual(mockWorkspace); + }); + + it('saves the workspace correctly to local storage', () => { + const mockWorkspace = { + id: 2, + setting: 'test2' + }; + + WorkspaceProvider.saveWorkspace(mockWorkspace); + + const savedWorkspace = JSON.parse(localStorage.getItem('pbte-workspace')); + + expect(savedWorkspace).toEqual(mockWorkspace); + }); + + it('clears the workspace from local storage', () => { + localStorage.setItem('pbte-workspace', JSON.stringify({ id: 3 })); + WorkspaceProvider.clear(); + + const workspace = localStorage.getItem('pbte-workspace'); + + expect(workspace).toBeNull(); + }); +});