Skip to content

Commit 82e1c7c

Browse files
committed
feat(tools): knobs elements
wip dev server knobs
1 parent 98a36a3 commit 82e1c7c

File tree

5 files changed

+185
-3
lines changed

5 files changed

+185
-3
lines changed

tools/pfe-tools/dev-server/plugins/pfe-dev-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { SiteOptions } from '../../config.js';
55
import { pfeDevServerRouterMiddleware } from './dev-server-router.js';
66
import { pfeDevServerTemplateMiddleware } from './dev-server-templates.js';
77

8-
export type PfeDevServerInternalConfig = Required<PfeDevServerConfigOptions> & {
8+
type PfeDevServerInternalConfig = Required<PfeDevServerConfigOptions> & {
99
site: Required<SiteOptions>;
1010
};
1111

tools/pfe-tools/dev-server/plugins/templates/index.html

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@
105105
<li>
106106
<details {{ 'open' if demo.primaryElementName == primary }}>
107107
<summary>{{ first.title }}</summary>
108-
<ul>{% for d in group %}
108+
<ul>
109+
<li>
110+
<a href="/components/{{ primary.replace('pf-', '') }}/demo/knobs/">Knobs</a>
111+
</li>{% for d in group %}
109112
<li>
110113
<a href="{{ d.permalink | replace(demoURLPrefix, '/') }}">{{ d.title }}</a>
111114
</li>{% endfor %}
@@ -128,7 +131,8 @@ <h2 slot="header">{{ first.title }}</h2>
128131
<a href="{{ first.permalink | replace(demoURLPrefix, '/') }}">
129132
<img src="/elements/{{ primary }}/docs/screenshot.png" alt="{{ primary }}">
130133
</a>
131-
<ul>{% for d in group %}{% if not loop.first %}
134+
<ul>
135+
<li><a href="/components/{{ primary.replace('pf-', '') }}/demo/knobs/">Knobs</a></li>{% for d in group %}{% if not loop.first %}
132136
<li>
133137
<a href="{{ d.permalink | replace(demoURLPrefix, '/') }}">{{ d.title }}</a>
134138
</li>{% endif %}{% endfor %}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<script type="module">
2+
import '@patternfly/pfe-tools/elements/pft-element-knobs.ts';
3+
import '{{ manifest.packageJson.name}}/{{ tagName }}/{{ tagName }}.js';
4+
// TODO: discriminate
5+
import '@patternfly/elements/pf-select/pf-select.js';
6+
import '@patternfly/elements/pf-text-input/pf-text-input.js';
7+
</script>
8+
9+
<pft-element-knobs tag="{{ tagName }}">
10+
<template>
11+
<{{ tagName }}></{{ tagName }}>
12+
</template>
13+
<script type="application/json" data-package="{{ manifest.packageJson.name }}">
14+
{{ manifest.manifest | dump(2) | safe }}
15+
</script>
16+
</pft-element-knobs>
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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+
}

tools/pfe-tools/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"./dev-server/config.js": "./dev-server/config.js",
2727
"./dev-server/demo.js": "./dev-server/demo.js",
2828
"./environment.js": "./environment.js",
29+
"./elements/": "./elements/",
2930
"./11ty/DocsPage.js": "./11ty/DocsPage.js",
3031
"./11ty/plugins/anchors.cjs": "./11ty/plugins/anchors.cjs",
3132
"./11ty/plugins/custom-elements-manifest.cjs": "./11ty/plugins/custom-elements-manifest.cjs",

0 commit comments

Comments
 (0)