DSD, open/closed template, and light-DOM components that talk through <main-context>: children register on… callbacks and share updates as events. Served by Vite via npm run examples; examples/ is linted, formatted and typechecked.
138 lines
4.0 KiB
TypeScript
138 lines
4.0 KiB
TypeScript
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 = `
|
|
<label>
|
|
<span class="field-label"></span>
|
|
<input>
|
|
</label>
|
|
<small class="field-hint"></small>
|
|
`;
|
|
|
|
/**
|
|
* Light DOM: `template` is appended into the element on first connect, so the
|
|
* `<input>` is a real child of the page and works with page CSS, autofill and
|
|
* `document.querySelector`. `css` goes into `<head>` once for every instance.
|
|
* The native `input` event bubbles out of the inner `<input>` to the host, which
|
|
* reports it to `<main-context>` 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<typeof SPEC>): 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<FieldInput>(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;
|
|
}
|
|
}
|