import { WebComponent, choice, enable, string, type PropChange } from '../src/index.ts'; import { dispatch } from './dispatch.ts'; import type { FieldInput } from './main-context.ts'; const css = ` labeled-field { display: grid; gap: 0.35rem; font: 0.9rem/1.3 system-ui, sans-serif; } labeled-field label { display: grid; gap: 0.35rem; } labeled-field .field-label { font-weight: 600; color: var(--ink, #1d1d29); } labeled-field[required] .field-label::after { content: ' *'; color: #e5484d; } labeled-field input { font: inherit; padding: 0.6rem 0.75rem; border: 1.5px solid var(--line, #d9d9e3); border-radius: 10px; background: var(--field, #fff); color: inherit; transition: border-color 150ms ease, box-shadow 150ms ease; } labeled-field input:focus { outline: none; border-color: #7c5cff; box-shadow: 0 0 0 4px rgb(124 92 255 / 18%); } labeled-field .field-hint { color: var(--muted, #6b6b7b); min-height: 1.3em; } labeled-field:state(invalid) input { border-color: #e5484d; } labeled-field:state(invalid) input:focus { box-shadow: 0 0 0 4px rgb(229 72 77 / 18%); } labeled-field:state(invalid) .field-hint { color: #e5484d; } `; const template = ` `; /** * Light DOM: `template` is appended into the element on first connect, so the * `` is a real child of the page and works with page CSS, autofill and * `document.querySelector`. `css` goes into `` once for every instance. * The native `input` event bubbles out of the inner `` to the host, which * reports it to `` as a `wc:field-input` keyed by `name`. */ const SPEC = { template, css, events: { input: true, }, element: { label: '.field-label', input: 'input', hint: '.field-hint' }, props: { name: string({ default: '' }), label: string({ default: '' }), value: string({ default: '' }), placeholder: string(), hint: string(), type: choice(['text', 'number', 'email'], { default: 'text' }), required: enable(), }, } as const; export class LabeledField extends WebComponent(SPEC) { override onConnect(): void { const { label, input, hint } = this.refs; if (!label || !input || !hint) return; label.textContent = this.label; input.type = this.type; input.value = this.value; input.required = this.required; if (this.placeholder !== undefined) input.placeholder = this.placeholder; this.#validate(); } override onPropChange(change: PropChange): void { // Attributes are decoded at upgrade, before the template is appended. const { label, input } = this.refs; if (!label || !input) return; switch (change.name) { case 'label': { label.textContent = change.value; break; } case 'value': { if (input.value !== change.value) input.value = change.value; break; } case 'placeholder': { input.placeholder = change.value ?? ''; break; } case 'type': { input.type = change.value; break; } case 'required': { input.required = change.value; break; } case 'name': case 'hint': { // `name` is read on input; `hint` is shown by #validate below. break; } } this.#validate(); } override handleEvent(): void { if (this.refs.input) { this.value = this.refs.input.value; } dispatch(this, 'wc:field-input', { name: this.name, value: this.value }); } // `internals.states` drives the `:state(invalid)` selectors above. #validate(): void { const { input, hint } = this.refs; if (!input || !hint) return; const valid = input.checkValidity(); if (valid) this.internals.states.delete('invalid'); else this.internals.states.add('invalid'); hint.textContent = valid ? (this.hint ?? '') : input.validationMessage; } } customElements.define('labeled-field', LabeledField); declare global { interface HTMLElementTagNameMap { 'labeled-field': LabeledField; } }