Skip to content

Instantly share code, notes, and snippets.

@oscarmarina
Created October 6, 2024 20:20
Show Gist options
  • Save oscarmarina/63c161ac4ab72d23fa7bb063b9bdf15f to your computer and use it in GitHub Desktop.
Save oscarmarina/63c161ac4ab72d23fa7bb063b9bdf15f to your computer and use it in GitHub Desktop.
Deeply inspired by Konnor Rogers' approach to - https://www.konnorrogers.com/posts/2024/making-lit-components-morphable
<!DOCTYPE html>
<html lang="en">
<head>
<title>Demo - morphable-element</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root {
font-family: sans-serif;
}
section {
max-width: 50rem;
margin: 1rem auto;
}
section div {
display: grid;
grid-template-columns: repeat(2, minmax(12.5rem, 1fr));
gap: 0.5rem;
}
</style>
</head>
<body>
<section>
<p>Default</p>
<div>
<morphable-element count="5" active message="hello">
<code></code>
</morphable-element>
<morphable-element message="hello123">
<code></code>
</morphable-element>
</div>
</section>
<section>
<p>Extend</p>
<div>
<morphable-element-extend><code></code></morphable-element-extend>
<morphable-element-extend count="10"><code></code></morphable-element-extend>
</div>
</section>
<script type="module" src="./morphable-element.js"></script>
<script type="module" src="./morphable-element-extend.js"></script>
</body>
</html>
import {MorphableElement} from './morphable-element.js';
export class MorphableElementExtend extends MorphableElement {
constructor() {
super();
this.message = undefined;
this.count = undefined;
}
}
window.customElements.define('morphable-element-extend', MorphableElementExtend);
import {css} from 'lit';
export const styles = css`
:host {
--spacing: 8px;
display: flex;
flex-direction: column;
align-items: start;
gap: var(--spacing);
padding: var(--spacing);
border-radius: calc(var(--spacing) / 2);
border: 1px dashed rgb(204, 204, 204);
margin-bottom: 1rem;
background-color: rgba(204, 204, 204, 0.2);
}
:host([hidden]),
[hidden] {
display: none !important;
}
*,
*::before,
*::after {
box-sizing: inherit;
}
button {
vertical-align: middle;
padding: 0.4em;
}
`;
import {html, LitElement} from 'lit';
import {until} from 'lit/directives/until.js';
import {MorphableMixin} from './Morphable.js';
import {styles} from './morphable-element-styles.css.js';
export class MorphableElement extends MorphableMixin(LitElement) {
static styles = styles;
static properties = {
message: {type: String, reflect: true},
count: {type: Number, reflect: true},
active: {type: Boolean, reflect: true},
};
constructor() {
super();
this.message = 'Hiya';
this.count = 11;
this.active = false;
}
connectedCallback() {
super.connectedCallback?.();
this.codeNode = this.querySelector('code');
}
render() {
return html`
<div><b>Change Properties</b></div>
<div>
message:
<input
.value=${this.message != null ? this.message : ''}
@input="${({target}) => {
this.message = target.value;
}}" />
</div>
<div>
active:
<input
type="checkbox"
.checked=${this.active}
@input="${({target}) => {
this.active = target.checked;
}}" />
</div>
<div>
count:
<button
@click=${() => {
this.count = (this.count ?? 0) + 1;
}}>
${this.count}
</button>
</div>
<div><b>Update Attributes</b></div>
<div>
<button
@click="${() => {
if (this.count != null) {
this.setAttribute('count', String(this.count));
}
if (this.message != null) {
this.setAttribute('message', this.message);
}
if (this.active) {
this.setAttribute('active', '');
}
}}">
set
</button>
<button
@click="${() => {
this.removeAttribute('message');
this.removeAttribute('count');
this.removeAttribute('active');
}}">
remove
</button>
</div>
<div><b>Results</b></div>
<ul>
<li>
message: attr = ${until(this.updateComplete.then(() => this.getAttribute('message')))},
prop = ${this.message}
</li>
<li>
count: attr = ${until(this.updateComplete.then(() => this.getAttribute('count')))}, prop =
${this.count}
</li>
<li>
active: attr = ${until(this.updateComplete.then(() => this.getAttribute('active')))}, prop
= ${this.active}
</li>
</ul>
<slot></slot>
`;
}
updated() {
if (this.codeNode) {
this.codeNode.textContent = '';
this.codeNode.textContent = this.outerHTML;
}
}
}
window.customElements.define('morphable-element', MorphableElement);
import {dedupeMixin} from '@open-wc/dedupe-mixin';
/**
* ![Lit](https://img.shields.io/badge/lit-3.0.0-blue.svg)
*
* Deeply inspired by Konnor Rogers' approach to [making Lit components morphable](https://www.konnorrogers.com/posts/2024/making-lit-components-morphable).
*
* This mixin ensures that the property is reset to its initial value when the attribute is removed.
* It applies to properties that have `reflect: true` and works even with those initialized as undefined, null, or false.
*
* ## Key Use Cases:
*
* 1. **Attributes**: The `attributeChangedCallback` is called first:
*
* ```html
* <morphable-element message="Hello" active count="5">some light-dom</morphable-element>
* ```
*
* 2. **No Attributes - properties with values**: The `attributeChangedCallback` is called first:
*
* ```html
* <morphable-element>some light-dom</morphable-element>
* ```
*
* ```js
* // Initial properties:
* constructor() {
* super();
* this.message = 'Hiya';
* this.count = 11;
* this.active = false;
* }
* ```
*
* 3. **No attributes, properties are undefined**: The `connectedCallback` is called first:
*
* ```html
* <morphable-element>some light-dom</morphable-element>
* ```
*
* ```js
* // Initial properties:
* constructor() {
* super();
* this.message = undefined;
* this.count = undefined;
* this.active = false;
* }
* ```
*
* 4. **Direct property setting**: If the property is set directly rather than through an attribute, such as:
*
* ```html
* <morphable-element .message="${message}" .active="${false}">some light-dom</morphable-element>
* ```
*
* There is no straightforward way to determine the value used in the constructor. This is because
* the property is set directly on the instance after it has been constructed, bypassing the attribute
* reflection mechanism.
*/
const MorphableBase = (Base) =>
class Morphable extends Base {
#initialProperties = new Map();
#setupInitialProperties() {
const {elementProperties} = this.constructor;
elementProperties.forEach((v, k) => {
if (v.reflect === true) {
const initialProperty = {
...v,
initialValue: this[k],
};
this.#initialProperties.set(k, initialProperty);
}
});
}
#ensureInitialProperties() {
if (!this.#initialProperties.size) {
this.#setupInitialProperties();
}
}
connectedCallback() {
this.#ensureInitialProperties();
super.connectedCallback?.();
}
/**
* @param {string} name
* @param {string|null} old
* @param {string|null} value
*/
async attributeChangedCallback(name, old, value) {
this.#ensureInitialProperties();
if (value != null) {
super.attributeChangedCallback?.(name, old, value);
return;
}
const propertyDetails = this.#initialProperties.get(name) ?? {};
const {initialValue, type} = propertyDetails;
if (!(type === Boolean && !this[name])) {
await this.updateComplete;
if (initialValue != null && initialValue !== false) {
this.setAttribute(name, initialValue);
} else {
this[name] = initialValue;
}
} else {
super.attributeChangedCallback?.(name, old, value);
}
}
};
export const MorphableMixin = dedupeMixin(MorphableBase);
{
"type": "module",
"dependencies": {
"@open-wc/dedupe-mixin": "^1.4.0",
"lit": "^3.2.0",
"@lit/reactive-element": "^2.0.4",
"lit-element": "^4.1.0",
"lit-html": "^3.2.0"
}
}
{
"files": {
"Morphable.js": {
"position": 0
},
"index.html": {
"position": 1
},
"morphable-element.js": {
"position": 2
},
"morphable-element-extend.js": {
"position": 3
},
"morphable-element-styles.css.js": {
"position": 4,
"hidden": true
},
"package.json": {
"position": 5,
"hidden": true
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment