|
| 1 | +import type { |
| 2 | + Attribute, |
| 3 | + CustomElementDeclaration, |
| 4 | + Declaration, |
| 5 | + Package, |
| 6 | +} from 'custom-elements-manifest'; |
| 7 | + |
| 8 | +import { LitElement, css, html, type PropertyValues } from 'lit'; |
| 9 | +import { customElement } from 'lit/decorators/custom-element.js'; |
| 10 | +import { property } from 'lit/decorators/property.js'; |
| 11 | +import { ifDefined } from 'lit/directives/if-defined.js'; |
| 12 | + |
| 13 | +type KnobRenderer<T> = ( |
| 14 | + this: PftElementKnobs<HTMLElement>, |
| 15 | + attribute: T, |
| 16 | + index: number, |
| 17 | + array: T[], |
| 18 | +) => unknown; |
| 19 | + |
| 20 | +export type AttributeRenderer = KnobRenderer<Attribute>; |
| 21 | + |
| 22 | +const isCustomElementDecl = (decl: Declaration): decl is CustomElementDeclaration => |
| 23 | + 'customElement' in decl; |
| 24 | + |
| 25 | +@customElement('pft-element-knobs') |
| 26 | +export class PftElementKnobs<T extends HTMLElement> extends LitElement { |
| 27 | + static styles = [ |
| 28 | + css` |
| 29 | + #element { |
| 30 | + padding: 1em; |
| 31 | + } |
| 32 | + fieldset { |
| 33 | + display: grid; |
| 34 | + gap: 4px; |
| 35 | + grid-template-columns: max-content 1fr; |
| 36 | + align-items: center; |
| 37 | + } |
| 38 | + `, |
| 39 | + ]; |
| 40 | + |
| 41 | + @property() tag?: string; |
| 42 | + |
| 43 | + @property({ attribute: false }) manifest?: Package; |
| 44 | + |
| 45 | + @property({ attribute: false }) element: T | null = null; |
| 46 | + |
| 47 | + @property({ attribute: false }) renderAttribute: AttributeRenderer = this.#renderAttribute; |
| 48 | + |
| 49 | + #mo = new MutationObserver(this.#loadTemplate); |
| 50 | + |
| 51 | + #node: DocumentFragment | null = null; |
| 52 | + |
| 53 | + #elementDecl: CustomElementDeclaration | null = null; |
| 54 | + |
| 55 | + override connectedCallback(): void { |
| 56 | + super.connectedCallback(); |
| 57 | + this.#mo.observe(this, { childList: true }); |
| 58 | + this.#loadTemplate(); |
| 59 | + } |
| 60 | + |
| 61 | + get #template() { |
| 62 | + return this.querySelector('template'); |
| 63 | + } |
| 64 | + |
| 65 | + protected willUpdate(changed: PropertyValues<this>): void { |
| 66 | + if (changed.has('manifest') || changed.has('tag')) { |
| 67 | + for (const mod of this.manifest?.modules ?? []) { |
| 68 | + for (const decl of mod.declarations ?? []) { |
| 69 | + if (isCustomElementDecl(decl) && decl.tagName === this.tag) { |
| 70 | + this.#elementDecl = decl; |
| 71 | + } |
| 72 | + } |
| 73 | + } |
| 74 | + } |
| 75 | + } |
| 76 | + |
| 77 | + #loadTemplate() { |
| 78 | + const script = this.querySelector('script[type="application/json"]'); |
| 79 | + if (script) { |
| 80 | + try { |
| 81 | + this.manifest = JSON.parse(script.textContent ?? ''); |
| 82 | + } catch { |
| 83 | + null; |
| 84 | + } |
| 85 | + } |
| 86 | + if (this.#template && this.tag) { |
| 87 | + this.#node = this.#template.content.cloneNode(true) as DocumentFragment; |
| 88 | + this.element = this.#node.querySelector(this.tag); |
| 89 | + } |
| 90 | + } |
| 91 | + |
| 92 | + #renderAttribute(attribute: Attribute) { |
| 93 | + const QUOTE_RE = /^['"](.*)['"]$/; |
| 94 | + // TODO: non-typescript types? |
| 95 | + const isBoolean = attribute?.type?.text === 'boolean'; |
| 96 | + const isUnion = !!attribute?.type?.text?.includes?.('|'); |
| 97 | + let isEnum = false; |
| 98 | + let values: string[]; |
| 99 | + if (isUnion) { |
| 100 | + values = attribute?.type?.text |
| 101 | + .split('|') |
| 102 | + .map(x => x.trim()) |
| 103 | + .filter(x => x !== 'undefined' && x !== 'null') ?? []; |
| 104 | + if (values.length > 1) { |
| 105 | + isEnum = true; |
| 106 | + } |
| 107 | + } |
| 108 | + const id = `knob-attribute-${attribute.name}`; |
| 109 | + return html` |
| 110 | + <label for="${id}">${attribute.name}</label>${isBoolean ? html` |
| 111 | + <input id="${id}" |
| 112 | + type="checkbox" |
| 113 | + ?checked="${attribute.default === 'true'}" |
| 114 | + data-attribute="${attribute.name}">` : isEnum ? html` |
| 115 | + <pf-select id="${id}" |
| 116 | + placeholder="Select a value" |
| 117 | + data-attribute="${attribute.name}" |
| 118 | + value="${ifDefined(attribute.default?.replace(QUOTE_RE, '$1'))}">${values!.map(x => html` |
| 119 | + <pf-option>${x.trim().replace(QUOTE_RE, '$1')}</pf-option>`)} |
| 120 | + </pf-select> |
| 121 | + ` : html` |
| 122 | + <pf-text-input id="${id}" |
| 123 | + value="${ifDefined(attribute.default?.replace(QUOTE_RE, '$1'))}" |
| 124 | + helper-text="${ifDefined(attribute.type?.text)}" |
| 125 | + data-attribute="${attribute.name}"></pf-text-input>`} |
| 126 | + `; |
| 127 | + } |
| 128 | + |
| 129 | + #renderKnobs() { |
| 130 | + const decl = this.#elementDecl; |
| 131 | + const { element, tag, manifest } = this; |
| 132 | + if (element && decl && tag && manifest) { |
| 133 | + const { attributes } = decl; |
| 134 | + |
| 135 | + const onChange = (e: Event & { target: HTMLInputElement }) => { |
| 136 | + if (e.target instanceof HTMLInputElement && e.target.type === 'checkbox') { |
| 137 | + this.element?.toggleAttribute(e.target.dataset.attribute!, e.target.checked); |
| 138 | + } else { |
| 139 | + this.element?.setAttribute(e.target.dataset.attribute!, e.target.value); |
| 140 | + } |
| 141 | + }; |
| 142 | + |
| 143 | + return html` |
| 144 | + <form @submit="${(e: Event) => e.preventDefault()}"> |
| 145 | + ${!attributes ? '' : html` |
| 146 | + <fieldset @change="${onChange}" @input="${onChange}"> |
| 147 | + <legend>Attributes</legend> |
| 148 | + ${attributes.map(this.renderAttribute, this)} |
| 149 | + </fieldset>`} |
| 150 | + </form> |
| 151 | + `; |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + protected override render(): unknown { |
| 156 | + return html` |
| 157 | + <div id="element">${this.#node ?? ''}</div> |
| 158 | + ${this.#renderKnobs() ?? ''} |
| 159 | + `; |
| 160 | + } |
| 161 | +} |
0 commit comments