Files
adaptive-pricing/projects/coulomb-pricing/ui/vendor/whynot-design/elements/form.js
tegwick da3b7d66f0 Integrate whynot-design into Economic Observatory UI
Vendor whynot-design Layer 1 (tokens, CSS) and Layer 2 (<wn-*>
components) via scripts/sync-whynot-design.sh with a pinned ref.
Migrate the observatory shell to canonical web components, keep
observatory-specific layout in styles.css, and add vendor integrity
tests plus correct JS MIME types on the dev server.
2026-06-22 03:09:44 +02:00

206 lines
7.6 KiB
JavaScript

/* =============================================================
* @whynot/design — form.js
* ------------------------------------------------------------
* <wn-input>, <wn-textarea>, <wn-select>,
* <wn-search-input>, <wn-field-row>
*
* Each wraps a real native element. Form participation works
* because the native input is part of the light DOM via the
* `name` attribute being copied through; for richer integration
* use ElementInternals (deferred — see CHANGELOG).
* ============================================================= */
import { LitElement, html, nothing } from "lit";
import { WnBase } from "./atoms.js";
/* ---------- <wn-input> ---------- */
export class WnInput extends WnBase {
static properties = {
name: { type: String, reflect: true },
type: { type: String, reflect: true },
value: { type: String },
placeholder: { type: String },
required: { type: Boolean, reflect: true },
disabled: { type: Boolean, reflect: true },
readonly: { type: Boolean, reflect: true },
autocomplete:{ type: String },
error: { type: Boolean, reflect: true },
help: { type: String },
errorText: { type: String, attribute: "error-text" },
};
constructor() {
super();
this.type = "text";
this.value = "";
this.required = false;
this.disabled = false;
this.readonly = false;
this.error = false;
}
_onInput(e) {
this.value = e.target.value;
this.dispatchEvent(new CustomEvent("wn-input", { detail: { value: this.value }, bubbles: true, composed: true }));
}
render() {
const cls = "wn-input" + (this.error ? " wn-input--error" : "");
return html`
<input class=${cls}
part="input"
name=${this.name ?? nothing}
type=${this.type}
.value=${this.value}
placeholder=${this.placeholder ?? nothing}
?required=${this.required}
?disabled=${this.disabled}
?readonly=${this.readonly}
autocomplete=${this.autocomplete ?? nothing}
@input=${this._onInput}>
${this.error && this.errorText
? html`<span class="wn-form-error">${this.errorText}</span>`
: this.help
? html`<span class="wn-form-help">${this.help}</span>`
: nothing}
`;
}
}
/* ---------- <wn-textarea> ---------- */
export class WnTextarea extends WnBase {
static properties = {
name: { type: String, reflect: true },
value: { type: String },
placeholder: { type: String },
rows: { type: Number },
required: { type: Boolean, reflect: true },
disabled: { type: Boolean, reflect: true },
error: { type: Boolean, reflect: true },
help: { type: String },
errorText: { type: String, attribute: "error-text" },
};
constructor() { super(); this.value = ""; this.rows = 4; }
_onInput(e) {
this.value = e.target.value;
this.dispatchEvent(new CustomEvent("wn-input", { detail: { value: this.value }, bubbles: true, composed: true }));
}
render() {
const cls = "wn-textarea" + (this.error ? " wn-textarea--error" : "");
return html`
<textarea class=${cls}
part="textarea"
name=${this.name ?? nothing}
rows=${this.rows}
placeholder=${this.placeholder ?? nothing}
?required=${this.required}
?disabled=${this.disabled}
@input=${this._onInput}
.value=${this.value}></textarea>
${this.error && this.errorText
? html`<span class="wn-form-error">${this.errorText}</span>`
: this.help
? html`<span class="wn-form-help">${this.help}</span>`
: nothing}
`;
}
}
/* ---------- <wn-select> ----------
* Slot <option> elements; they're cloned into the inner <select>. */
export class WnSelect extends WnBase {
static properties = {
name: { type: String, reflect: true },
value: { type: String },
required: { type: Boolean, reflect: true },
disabled: { type: Boolean, reflect: true },
error: { type: Boolean, reflect: true },
help: { type: String },
};
_onSlotChange(e) {
const slot = e.target;
const select = this.shadowRoot?.querySelector("select.wn-select");
if (!select) return;
const options = slot.assignedElements({ flatten: true }).filter(el => el.tagName === "OPTION");
select.innerHTML = "";
for (const opt of options) select.appendChild(opt.cloneNode(true));
if (this.value != null) select.value = this.value;
}
_onChange(e) {
this.value = e.target.value;
this.dispatchEvent(new CustomEvent("wn-change", { detail: { value: this.value }, bubbles: true, composed: true }));
}
render() {
const cls = "wn-select" + (this.error ? " wn-select--error" : "");
return html`
<select class=${cls}
part="select"
name=${this.name ?? nothing}
?required=${this.required}
?disabled=${this.disabled}
@change=${this._onChange}></select>
<slot @slotchange=${this._onSlotChange} style="display:none"></slot>
${this.help ? html`<span class="wn-form-help">${this.help}</span>` : nothing}
`;
}
}
/* ---------- <wn-search-input> ---------- */
export class WnSearchInput extends WnBase {
static properties = {
placeholder: { type: String },
kbd: { type: String },
value: { type: String },
name: { type: String, reflect: true },
};
constructor() { super(); this.placeholder = "Search…"; this.kbd = "⌘ K"; this.value = ""; }
_onInput(e) {
this.value = e.target.value;
this.dispatchEvent(new CustomEvent("wn-input", { detail: { value: this.value }, bubbles: true, composed: true }));
}
render() {
return html`
<label class="wn-search" part="root">
<wn-icon name="search" size="sm"></wn-icon>
<input type="search"
name=${this.name ?? nothing}
.value=${this.value}
placeholder=${this.placeholder}
@input=${this._onInput}>
${this.kbd ? html`<span class="wn-search__kbd">${this.kbd}</span>` : nothing}
</label>
`;
}
}
/* ---------- <wn-field-row> ---------- */
export class WnFieldRow extends WnBase {
static properties = {
label: { type: String },
aside: { type: String },
stacked: { type: Boolean, reflect: true },
narrow: { type: Boolean, reflect: true },
htmlFor: { type: String, attribute: "for" },
};
render() {
const cls = ["wn-field-row",
this.stacked ? "wn-field-row--stacked" : "",
this.narrow ? "wn-field-row--narrow" : "",
].filter(Boolean).join(" ");
return html`
<div class=${cls} part="root">
<label class="wn-field-row__label" for=${this.htmlFor ?? nothing}>${this.label}</label>
<div class="wn-field-row__value"><slot></slot></div>
${this.aside
? html`<div class="wn-field-row__aside">${this.aside}<slot name="aside"></slot></div>`
: html`<div class="wn-field-row__aside"><slot name="aside"></slot></div>`}
</div>
`;
}
}
export function defineForm() {
if (!customElements.get("wn-input")) customElements.define("wn-input", WnInput);
if (!customElements.get("wn-textarea")) customElements.define("wn-textarea", WnTextarea);
if (!customElements.get("wn-select")) customElements.define("wn-select", WnSelect);
if (!customElements.get("wn-search-input")) customElements.define("wn-search-input", WnSearchInput);
if (!customElements.get("wn-field-row")) customElements.define("wn-field-row", WnFieldRow);
}