wc-mixin/examples/labeled-field.ts
Buddy 76d78e1925 Accept a CSSStyleSheet as css; move example CSS and templates into files
css is now string | CSSStyleSheet. A sheet is adopted as-is: into its own
shadow root, an enclosing shadow root, or document.adoptedStyleSheets for
light DOM at the document level. Strings behave as before.

The examples import their styles with `with { type: 'css' }` and their
static templates with `with { type: 'text' }`. Safari doesn't support those
yet, so vite.config.ts rewrites them into modules Vite builds: a constructed
CSSStyleSheet, or the file's text. npm run examples now just runs vite,
with root set in the config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:44:27 -08:00

103 lines
3.1 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';
import css from './labeled-field.css' with { type: 'css' };
import template from './labeled-field.html' with { type: 'text' };
/**
* 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` is adopted into the document 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;
}
}