theme-switch-component
Version:
Switch your site's theme
671 lines (620 loc) • 22.1 kB
JavaScript
/**
* <theme-switch> is a web component which enables
* users to switch between themes.
*
* Copyright © 2021 Tony Spegel
*/
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
import { LitElement, css, html } from 'lit';
import { DialogEvent } from './DialogEvent';
import { ThemeEvent } from './ThemeEvent';
import { customElement, eventOptions, property, queryAll, state, } from 'lit/decorators.js';
const readSetting = (setting) => {
return localStorage.getItem(setting);
};
const saveSetting = (setting, value) => {
localStorage.setItem(setting, value);
};
const deleteSetting = (setting) => {
localStorage.removeItem(setting);
};
/**
* These are default themes most users will use
*/
const defaulThemes = [
{ name: 'auto', checked: true },
{ name: 'light', checked: false },
{ name: 'dark', checked: false },
];
/**
* The component can be configured by the 'availableThemes' property.
* As these are just strings, they need to be upgraded by
* adding a name and checked property
*/
const upgradeToTheme = (names) => {
return names.map((name, index) => {
return { name, checked: index === 0 ? true : false };
});
};
/**
* Custom element which helps switching themes.
*/
let ThemeSwitch = class ThemeSwitch extends LitElement {
constructor() {
super(...arguments);
/**
* States ✨ + Decorators
* ======================
*/
// Used to toggle the dialog's visibility
this.dialogHidden = true;
// Represents radio buttons to select a theme
this.themes = [];
// If it's allowed to write to localStorage
this.saveSelection = false;
/**
* Use availableThemes='["🐢", "🦕", "🐸"]' or don't
* and rely on default themes
*/
this.availableThemes = [];
// The index of the last radio button / theme
this.lastIndex = this.themes.length - 1;
}
/**
* Custom element lifecycle events
* ===============================
*/
/**
* Invoked when a component is added to the document's DOM.
*/
connectedCallback() {
super.connectedCallback();
/**
* Listen for special event to change the visibility of the component
* and safe a reference to the element which has dispatched it
*/
addEventListener(DialogEvent.eventName, async (event) => {
/**
* targetElement can be any HTMLElement
* which has opened the dialog.
*/
const { targetElement } = event;
/**
* targetElement's id is used to focus() it
* after closing the dialog which until then
* had its focus trapped
*/
const { id } = targetElement;
this.openerElementId = id;
/**
* If that event has been received,
* it's time to set the dialogHidden state to false.
* This triggers the 'reactive update cycle'
*/
this.dialogHidden = false;
/**
* Because ^ this happens to be asynchronous,
* we have to wait for updateComplete to resolve
* before doing any further work on any DOM element
*/
await this.updateComplete;
// When this is done we are ready so focus the first element
this.getTabElements()[0].focus();
});
// Read user settings from localStorage
const preference = readSetting('theme-preference');
const saveSelection = readSetting('save-selection');
/**
* Try reading a boolean value from localStorage
*/
try {
this.saveSelection = JSON.parse(saveSelection || '');
}
catch (e) {
this.saveSelection = false;
}
/**
* If themes have been configured through
* <theme-switch availableThemes='["🐢", "🦕", "🐸"]'></theme-switch>
* upgrade those otherwise use the default ones
*/
if (Array.isArray(this.availableThemes) &&
this.availableThemes.length) {
// Themes are upgraded by adding keys
this.themes = upgradeToTheme(this.availableThemes);
}
else {
// Use the default themes if none are set
this.themes = defaulThemes;
}
// Find the index in order to select a prefered theme
const index = this.themes.findIndex((themes) => themes.name === preference);
/**
* Upgrade the related theme by using the index,
* default to 0 if none was found
*/
this.updateThemeState(index === -1 ? 0 : index);
this.lastIndex = this.themes.length - 1;
}
/**
* Invoked when a component is removed from the document's DOM.
*/
disconnectedCallback() {
super.disconnectedCallback();
removeEventListener(DialogEvent.eventName, () => {
console.info(`${DialogEvent.eventName} has been removed as an EventListener`);
});
}
/**
* Methods ✨
* ===============
*/
/**
* Changes the state array of our themes
*/
updateThemeState(index) {
// Create a copy of 'themes'
const themesCopy = [...this.themes];
// Reset every theme to unchecked
themesCopy.forEach((theme) => (theme.checked = false));
// Update only that theme which has been selected
themesCopy[index].checked = true;
// Overwrite themes with our copy to trigger the reactive update cycle
this.themes = themesCopy;
}
/**
* Closes the dialog by setting a the dialogHidden state to false
*/
closeDialog() {
var _a;
this.dialogHidden = true;
// Refactor to its own method
(_a = document
.querySelector(`#${this.openerElementId}`)) === null || _a === void 0 ? void 0 : _a.focus();
}
/**
* Arrow keys need to be handled because non standard radion buttons are used
*/
handleArrowKeys(event, currentIndex) {
switch (event.key) {
case 'ArrowLeft':
case 'ArrowUp':
if (currentIndex !== 0) {
this.focusElement(this.themeButtons, currentIndex - 1);
}
else {
this.focusElement(this.themeButtons, this.lastIndex);
}
break;
case 'ArrowRight':
case 'ArrowDown':
if (currentIndex !== this.lastIndex) {
this.focusElement(this.themeButtons, currentIndex + 1);
}
else {
this.focusElement(this.themeButtons, 0);
}
break;
default:
break;
}
}
/**
* Get elements which should be accessible by using the tab key.
* This needs to be done because non standard radio buttons are
* used and the radio button which can be reached changes based
* selection.
*/
getTabElements() {
return this.renderRoot.querySelectorAll('a[href], input, button:not([tabindex="-1"])');
}
/**
* Used to focus on non standard radio buttons and other elements
*/
focusElement(nodes, index) {
nodes[index].focus();
}
close() { }
render() {
return html `
<div
aria-hidden="true"
tabindex="0"
@focus="${() => {
const el = this.getTabElements();
const index = el.length - 1;
this.focusElement(el, index);
}}"
></div>
<div
@click="${(e) => {
const { id } = e.target;
if (id !== 'dialog-backdrop') {
return;
}
this.closeDialog();
}}"
aria-hidden="${this.dialogHidden}"
id="dialog-backdrop"
>
<div
@keydown="${(event) => {
if (event.key === 'Escape') {
this.closeDialog();
}
}}"
aria-hidden="${this.dialogHidden}"
aria-label="Theme-Selection"
aria-modal="true"
id="dialog-theme-selection"
role="dialog"
tabindex="-1"
>
<div class="dialog-title">
<slot name="heading"></slot>
<slot name="sub-heading"></slot>
</div>
<div role="radiogroup" class="themes">
${this.themes.map((theme, index) => {
return html `
<div class="theme">
<div class="circle-wrapper">
<button
@click="${() => {
// Update state
this.updateThemeState(index);
// Save selection if user has agreed to do so
if (this.saveSelection) {
saveSetting('theme-preference', theme.name);
}
// Send event which theme has been selected
window.dispatchEvent(new ThemeEvent(theme.name));
}}"
@keydown="${(event) => {
this.handleArrowKeys(event, index);
if (event.key === 'Enter') {
this.updateThemeState(index);
}
}}"
aria-checked="${theme.checked}"
class="radio inner-circle"
id="${theme.name}"
role="radio"
tabindex=${theme.checked ? 0 : -1}
title="${theme.name}-Theme aktivieren"
></button>
<div class="outer-circle"></div>
</div>
<label for="${theme.name}">
${theme.name}
</label>
</div>
`;
})}
</div>
<div class="save-settings">
<div>
<input
?checked=${this.saveSelection}
@click=${() => {
if (this.saveSelection === false) {
const { name } = this.themes.filter((theme) => theme.checked === true)[0];
saveSetting('save-selection', 'true');
saveSetting('theme-preference', name);
}
else {
deleteSetting('save-selection');
deleteSetting('theme-preference');
}
this.saveSelection = !this.saveSelection;
}}
id="save-selection"
name="save-selection"
type="checkbox"
/>
<label for="save-selection">
Auswahl speichern
</label>
</div>
<slot name="read-more"></slot>
</div>
<button
@click=${this.closeDialog}
class="dialog-control"
id="btn-close-dialog"
title="Dialog schließen"
>
<slot name="close-caption"></slot>
</button>
</div>
<div
@focus="${() => {
const el = this.getTabElements();
this.focusElement(el, 0);
}}"
aria-hidden="true"
tabindex="0"
></div>
</div>
`;
}
};
ThemeSwitch.styles = css `
:host {
--purple-50: #faf5ff;
--purple-100: #f3e8ff;
--purple-200: #e9d5ff;
--purple-300: #d8b4fe;
--purple-400: #c084fc;
--purple-500: #a855f7;
--purple-600: #9333ea;
--purple-700: #7e22ce;
--purple-800: #6b21a8;
--purple-900: #581c87;
--purple-950: #2f0050;
--base-gap: 8px;
--base-radius: 8px;
--blur-amount: 5px;
--backdrop-color: hsla(0, 0, 78%, 0.1);
--text-color-1: var(--purple-950);
--text-color-2: var(--purple-900);
--outline-color: #000;
/* Dialog */
--dialog-bg-color: var(--purple-50);
--dialog-border-color: var(--purple-500);
/* Themes Wrapper */
--themes-border-color: var(--purple-400);
/* Radio Buttons */
--circle-bg-color: var(--purple-100);
--circle-bg-color-checked: var(--purple-300);
--circle-border-color: var(--purple-500);
/* Control elements */
--control-color: var(--purple-300);
--control-interaction-color: var(--purple-400);
/* Checkbox */
--checkbox-bg-color: var(--purple-50);
--checkbox-bg-color-checked: var(--purple-200);
--checkmark-color: var(--purple-900);
--checkbox-border-color: var(--purple-500);
font-family: var(--font-fam, sans-serif);
}
::slotted(h2) {
margin: 0;
color: var(--text-color-1);
}
::slotted(span) {
color: var(--text-color-2);
}
::slotted(#read-more) {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
padding: 4px;
width: 30px;
height: 30px;
text-decoration: none;
color: var(--text-color-1);
transition: transform 50ms ease-in-out,
background-color 100ms ease-in-out;
background-color: var(--control-color);
box-sizing: border-box;
}
::slotted(a#read-more:hover),
::slotted(a#read-more:focus) {
background-color: var(--control-interaction-color);
}
#dialog-backdrop {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 999;
background-color: var(
--backdrop-color,
hsla(260deg, 55%, 35%, 19%)
);
backdrop-filter: blur(var(--blur-amount, 5px));
}
#dialog-backdrop[aria-hidden='true'] {
display: none;
}
/**
* Dialog
*/
#dialog-theme-selection {
display: flex;
flex-direction: column;
gap: calc(var(--base-gap) * 1.5);
position: absolute;
left: 50%;
top: 50%;
z-index: 1000;
outline: none;
border: 3px solid var(--dialog-border-color);
border-radius: calc(var(--base-radius) * 2);
padding: calc(var(--base-gap) * 1.5);
width: 250px;
transform: translate(-50%, -50%);
background-color: var(--dialog-bg-color);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19),
0 6px 6px rgba(0, 0, 0, 0.23);
text-align: center;
}
#dialog-theme-selection[aria-hidden='true'] {
display: none;
}
label {
color: var(--text-color-1);
cursor: pointer;
}
.themes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(50px, 1fr));
gap: calc(var(--base-gap) / 2);
border: 1px solid var(--themes-border-color);
border-radius: var(--base-radius);
padding: calc(var(--base-gap) / 2);
}
.theme {
display: flex;
flex-direction: column;
align-items: center;
gap: calc(var(--base-gap) / 4);
border-radius: var(--base-radius);
padding: calc(var(--base-gap) / 2);
text-transform: capitalize;
}
.circle-wrapper {
display: grid;
place-items: center;
grid-template-areas: 'circle';
width: 100%;
max-width: 45px;
aspect-ratio: 1;
}
.inner-circle {
grid-area: circle;
z-index: 1;
background-color: var(--circle-bg-color);
border-color: var(--circle-border-color);
}
.outer-circle {
grid-area: circle;
z-index: 0;
border-radius: 50%;
width: 50%;
aspect-ratio: 1;
background-color: var(--circle-wave);
opacity: 0.1;
transition: transform 0.4s cubic-bezier(0.54, 1.5, 0.38, 1.2);
}
.radio {
cursor: pointer;
border-radius: 50%;
width: 60%;
aspect-ratio: 1;
border: 2px solid var(--circle-border-color);
transition: transform 0.4s cubic-bezier(0.54, 1.5, 0.38, 1.2);
outline: none;
}
.radio:hover + .outer-circle,
.radio:focus + .outer-circle {
transform: scale(2);
}
.radio[aria-checked='true'].inner-circle {
/* background-color: var(--surface-3); */
/* background-color: var(--circle-checked); */
background-color: var(--circle-bg-color-checked);
}
.save-settings {
display: flex;
align-items: center;
justify-content: center;
gap: var(--base-gap);
}
.dialog-control {
color: var(--text-color-1);
transition: transform 50ms ease-in-out,
background-color 100ms ease-in-out;
background-color: var(--control-color);
}
.dialog-control:hover,
.dialog-control:focus {
background-color: var(--control-interaction-color);
}
#btn-close-dialog {
cursor: pointer;
border: none;
border-radius: var(--base-radius);
margin: 0 auto;
padding: calc(var(--base-gap) / 2);
width: 80%;
text-transform: capitalize;
}
input[type='checkbox'] {
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
clip: rect(1px, 1px, 1px, 1px);
}
input[type='checkbox'] + label {
display: grid;
align-items: center;
gap: var(--base-gap);
grid-template-areas: 'checkbox-cell label';
grid-template-columns: auto 1fr;
}
input[type='checkbox'] + label:before {
content: '';
grid-area: checkbox-cell;
border-radius: 2px;
border: 1px solid var(--checkbox-border-color);
width: 20px;
height: 20px;
background: var(--checkbox-bg-color);
}
input[type='checkbox']:checked + label:before {
background: var(--checkbox-bg-color-checked);
}
input[type='checkbox']:checked + label::after {
content: '';
border-left: 2px solid var(--checkmark-color);
border-bottom: 2px solid var(--checkmark-color);
height: 5px;
width: 12px;
transform: rotate(-45deg);
position: relative;
left: 4px;
grid-area: checkbox-cell;
top: -2px;
}
input[type='checkbox']:focus + label::before {
outline: var(--outline-color) solid 1px;
}
@media (prefers-reduced-motion: no-preference) {
#read-more:active,
#btn-close-dialog:active {
transform: scale(0.95);
}
}
`;
__decorate([
state()
], ThemeSwitch.prototype, "dialogHidden", void 0);
__decorate([
state()
], ThemeSwitch.prototype, "themes", void 0);
__decorate([
state()
], ThemeSwitch.prototype, "saveSelection", void 0);
__decorate([
property({ type: Array })
], ThemeSwitch.prototype, "availableThemes", void 0);
__decorate([
queryAll('button[role="radio"]')
], ThemeSwitch.prototype, "themeButtons", void 0);
__decorate([
eventOptions({})
], ThemeSwitch.prototype, "close", null);
ThemeSwitch = __decorate([
customElement('theme-switch')
], ThemeSwitch);
export { ThemeSwitch };
//# sourceMappingURL=theme-switch.js.map