generated from coulomb/repo-seed
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.
206 lines
7.6 KiB
JavaScript
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);
|
|
}
|