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>
This commit is contained in:
Buddy 2026-11-10 11:44:27 -08:00
parent 8865b49f4e
commit 76d78e1925
24 changed files with 299 additions and 210 deletions

View File

@ -43,8 +43,8 @@ ExampleElement.observedAttributes; // readonly ('user-id' | 'min' | 'is-ready' |
- **Props** are stored as values and reflected to attributes; attribute changes decode back. `onPropChange` fires once per real change from either side.
- **Attribute names**: an explicit `attribute` is used verbatim; otherwise `kebab(key)`, with `disable-` for `disable()` and `data-` in front when `dataPrefix: true`.
- **Codecs**: `string`, `number`, `enable`, `disable`, `choice([...])`, `json<T>()`, `prop({ default, decode, encode })`.
- **Shadow DOM**: `shadow` attaches a root and clones `template` into it; `css` is a shared adopted stylesheet. An existing declarative shadow root is used instead (and `shadow`/`template` are ignored). `root` is the shadow root (even when closed) or the element itself.
- **Light DOM**: `template` is appended on first connect; `css` is added once per element type, as a `<style>` in `<head>` or adopted into the enclosing shadow root.
- **Shadow DOM**: `shadow` attaches a root and clones `template` into it; `css` (a string or a `CSSStyleSheet`, e.g. `import sheet from './x.css' with { type: 'css' }`) is a shared adopted stylesheet. An existing declarative shadow root is used instead (and `shadow`/`template` are ignored). `root` is the shadow root (even when closed) or the element itself.
- **Light DOM**: `template` is appended on first connect; `css` is added once per element type: adopted into the enclosing shadow root, or at the document level a string becomes a `<style>` in `<head>` and a `CSSStyleSheet` is adopted into `document.adoptedStyleSheets`.
- **Refs**: a selector string is typed from its tag (last compound: `'form > button#go'` → `HTMLButtonElement`), falling back to `Element`. `[selector, Class]` types the ref as `Class`; reading it throws a `TypeError` if a match isn't an instance of `Class`. A key can't be in both `element` and `elements`.
- **`signal`** aborts on disconnect; a new one is created on each connect. `events` are listened on the host with it.
- **Events**: `events` maps a name to `true`, listener options, `listen<E>(options?)` for an explicit event type, or `custom<T>(options?)` for a `CustomEvent<T>` (the same as `listen<CustomEvent<T>>()`): `{ click: true, scroll: { passive: true }, 'wc:key': listen<KeyboardEvent>(), 'wc:pick': custom<number>() }`. Other names are typed from `HTMLElementEventMap` (`click` → `PointerEvent`), else `Event`. `handleEvent` receives `HandledEvent<typeof SPEC>`, a union whose `type` is literal, so `if (event.type === 'wc:pick') event.detail` narrows. A host listener can't see into a closed shadow root, so `composedPath()` stops at the host there.
@ -53,4 +53,4 @@ ExampleElement.observedAttributes; // readonly ('user-id' | 'min' | 'is-ready' |
## Scripts
`npm run lint` (oxlint, type-aware), `npm run format` / `format:check` (oxfmt), `npm run typecheck`, `npm test` (Vitest in headless Chromium + type tests), `npm run build`.
`npm run lint` (oxlint, type-aware), `npm run format` / `format:check` (oxfmt), `npm run typecheck`, `npm test` (Vitest in headless Chromium + type tests), `npm run build`, `npm run examples` (Vite dev server for `examples/`; `vite.config.ts` builds `with { type: 'css' }` / `with { type: 'text' }` imports for browsers without import-attribute support).

View File

@ -0,0 +1,29 @@
color-swatch-picker { display: block; font: 0.9rem/1.3 system-ui, sans-serif; }
color-swatch-picker fieldset { border: 0; margin: 0; padding: 0; }
color-swatch-picker legend { font-weight: 600; padding: 0; margin-bottom: 0.35rem; }
color-swatch-picker .swatches { display: flex; flex-wrap: wrap; gap: 0.5rem; }
color-swatch-picker .swatch {
position: relative;
display: grid;
place-items: end center;
width: 5.5rem;
height: 3.5rem;
padding-bottom: 0.3rem;
border-radius: 12px;
background: var(--swatch);
color: #fff;
font-weight: 600;
text-transform: capitalize;
text-shadow: 0 1px 2px rgb(0 0 0 / 35%);
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease;
}
color-swatch-picker .swatch:hover { transform: translateY(-2px); }
color-swatch-picker .swatch input { position: absolute; opacity: 0; pointer-events: none; }
color-swatch-picker .swatch:has(:checked) {
box-shadow: 0 0 0 3px var(--page, #fff), 0 0 0 5px var(--ink, #1d1d29);
}
color-swatch-picker .swatch:has(:focus-visible) {
outline: 2px solid #7c5cff;
outline-offset: 6px;
}

View File

@ -1,6 +1,7 @@
import { WebComponent, choice, string } from '../src/index.ts';
import { share } from './main-context.ts';
import { SWATCHES, THEMES } from './themes.ts';
import css from './color-swatch-picker.css' with { type: 'css' };
/**
* Light DOM radio group. The radios are real light-DOM `<input>`s, so arrow
@ -22,37 +23,7 @@ const SPEC = {
</div>
</fieldset>
`,
css: `
color-swatch-picker { display: block; font: 0.9rem/1.3 system-ui, sans-serif; }
color-swatch-picker fieldset { border: 0; margin: 0; padding: 0; }
color-swatch-picker legend { font-weight: 600; padding: 0; margin-bottom: 0.35rem; }
color-swatch-picker .swatches { display: flex; flex-wrap: wrap; gap: 0.5rem; }
color-swatch-picker .swatch {
position: relative;
display: grid;
place-items: end center;
width: 5.5rem;
height: 3.5rem;
padding-bottom: 0.3rem;
border-radius: 12px;
background: var(--swatch);
color: #fff;
font-weight: 600;
text-transform: capitalize;
text-shadow: 0 1px 2px rgb(0 0 0 / 35%);
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease;
}
color-swatch-picker .swatch:hover { transform: translateY(-2px); }
color-swatch-picker .swatch input { position: absolute; opacity: 0; pointer-events: none; }
color-swatch-picker .swatch:has(:checked) {
box-shadow: 0 0 0 3px var(--page, #fff), 0 0 0 5px var(--ink, #1d1d29);
}
color-swatch-picker .swatch:has(:focus-visible) {
outline: 2px solid #7c5cff;
outline-offset: 6px;
}
`,
css,
events: {
change: true,
},

50
examples/hero-banner.css Normal file
View File

@ -0,0 +1,50 @@
:host {
--a: #ff5f6d;
--b: #ffc371;
--c: #ff5f6d;
--glow: rgb(255 95 109 / 45%);
}
:host([theme='ocean']) {
--a: #2193b0;
--b: #6dd5ed;
--c: #b2fefa;
--glow: rgb(33 147 176 / 45%);
}
:host([theme='neon']) {
--a: #f72585;
--b: #7209b7;
--c: #4cc9f0;
--glow: rgb(114 9 183 / 55%);
}
.stage {
background:
radial-gradient(60% 80% at 15% 10%, color-mix(in srgb, var(--a) 35%, transparent), transparent),
radial-gradient(50% 70% at 85% 90%, color-mix(in srgb, var(--c) 30%, transparent), transparent),
#0d0d14;
box-shadow: 0 30px 80px -30px var(--glow);
transition: box-shadow 400ms ease;
}
h1 {
background: linear-gradient(100deg, var(--a), var(--b), var(--c), var(--a)) 0 0 / 300% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: flow 6s linear infinite;
filter: drop-shadow(0 4px 24px var(--glow));
}
.tagline {
animation: rise 700ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@keyframes flow {
to { background-position: 300% 0; }
}
@keyframes rise {
from { opacity: 0; transform: translateY(8px); }
}
@media (prefers-reduced-motion: reduce) {
h1, .tagline { animation: none; }
}

View File

@ -1,59 +1,7 @@
import { WebComponent, choice, string, type PropChange } from '../src/index.ts';
import { THEMES } from './themes.ts';
import { register } from './main-context.ts';
const css = `
:host {
--a: #ff5f6d;
--b: #ffc371;
--c: #ff5f6d;
--glow: rgb(255 95 109 / 45%);
}
:host([theme='ocean']) {
--a: #2193b0;
--b: #6dd5ed;
--c: #b2fefa;
--glow: rgb(33 147 176 / 45%);
}
:host([theme='neon']) {
--a: #f72585;
--b: #7209b7;
--c: #4cc9f0;
--glow: rgb(114 9 183 / 55%);
}
.stage {
background:
radial-gradient(60% 80% at 15% 10%, color-mix(in srgb, var(--a) 35%, transparent), transparent),
radial-gradient(50% 70% at 85% 90%, color-mix(in srgb, var(--c) 30%, transparent), transparent),
#0d0d14;
box-shadow: 0 30px 80px -30px var(--glow);
transition: box-shadow 400ms ease;
}
h1 {
background: linear-gradient(100deg, var(--a), var(--b), var(--c), var(--a)) 0 0 / 300% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: flow 6s linear infinite;
filter: drop-shadow(0 4px 24px var(--glow));
}
.tagline {
animation: rise 700ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@keyframes flow {
to { background-position: 300% 0; }
}
@keyframes rise {
from { opacity: 0; transform: translateY(8px); }
}
@media (prefers-reduced-motion: reduce) {
h1, .tagline { animation: none; }
}
`;
import css from './hero-banner.css' with { type: 'css' };
/**
* Declarative shadow DOM: the shadow root and its layout `<style>` are written

View File

@ -61,8 +61,8 @@
<h2>Controls <span class="kind">light DOM</span></h2>
<p>
<code>&lt;labeled-field&gt;</code> and <code>&lt;color-swatch-picker&gt;</code> render
into the light DOM; their CSS lives in a <code>&lt;style&gt;</code> in
<code>&lt;head&gt;</code>. They never touch the other components: each edit is
into the light DOM; their CSS is a CSS module adopted into
<code>document.adoptedStyleSheets</code>. They never touch the other components: each edit is
shared with <code>&lt;main-context&gt;</code>, which calls the
<code>on…</code> callbacks the other components registered.
</p>

View File

@ -0,0 +1,26 @@
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; }

View File

@ -0,0 +1,5 @@
<label>
<span class="field-label"></span>
<input>
</label>
<small class="field-hint"></small>

View File

@ -1,48 +1,13 @@
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>
`;
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` goes into `<head>` once for every instance.
* `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`.
*/

View File

@ -0,0 +1 @@
main-context { display: contents; }

View File

@ -1,6 +1,7 @@
import { WebComponent, custom, type HandledEvent } from '../src/index.ts';
import { dispatch } from './dispatch.ts';
import type { Theme } from './themes.ts';
import css from './main-context.css' with { type: 'css' };
/** Every update components can share through `<main-context>`: name → value. */
export interface Updates {
@ -52,7 +53,7 @@ export function share<K extends keyof Updates>(el: HTMLElement, name: K, value:
*/
const SPEC = {
// Light DOM with no template: a pure wrapper that doesn't affect layout.
css: 'main-context { display: contents; }',
css,
events: {
'wc:register': custom<Registration>(),
'wc:field-input': custom<FieldInput>(),

11
examples/modules.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
// `import sheet from './x.css' with { type: 'css' }` gives a constructed stylesheet.
declare module '*.css' {
const sheet: CSSStyleSheet;
export default sheet;
}
// `import text from './x.html' with { type: 'text' }` gives the file's contents.
declare module '*.html' {
const text: string;
export default text;
}

32
examples/secret-note.css Normal file
View File

@ -0,0 +1,32 @@
:host {
display: grid;
gap: 0.75rem;
padding: 1.25rem;
border-radius: 14px;
background: repeating-linear-gradient(-45deg, #1f1b2e 0 12px, #241f36 12px 24px);
color: #f0eaff;
font: 0.95rem/1.5 ui-monospace, 'SF Mono', Menlo, monospace;
}
button {
all: unset;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.5rem;
justify-self: start;
padding: 0.4rem 0.8rem;
border-radius: 999px;
background: #3b3355;
transition: background 150ms ease;
}
button:hover { background: #4a4170; }
button:focus-visible { outline: 2px solid #b69cff; outline-offset: 2px; }
.lock { transition: transform 300ms ease; }
.message {
margin: 0;
filter: blur(6px);
user-select: none;
transition: filter 400ms ease;
}
:host([revealed]) .message { filter: none; user-select: auto; }
:host([revealed]) .lock { transform: rotate(-20deg); }

View File

@ -0,0 +1,5 @@
<button type="button" aria-expanded="false">
<span class="lock" aria-hidden="true">🔒</span>
<span class="hint">Click to reveal</span>
</button>
<p class="message"></p>

View File

@ -1,5 +1,7 @@
import { WebComponent, enable, string, type PropChange } from '../src/index.ts';
import { register } from './main-context.ts';
import css from './secret-note.css' with { type: 'css' };
import template from './secret-note.html' with { type: 'text' };
/**
* Closed shadow root built from `template`: `el.shadowRoot` is `null` from the
@ -9,47 +11,8 @@ import { register } from './main-context.ts';
*/
const SPEC = {
shadow: 'closed',
template: `
<button type="button" aria-expanded="false">
<span class="lock" aria-hidden="true">🔒</span>
<span class="hint">Click to reveal</span>
</button>
<p class="message"></p>
`,
css: `
:host {
display: grid;
gap: 0.75rem;
padding: 1.25rem;
border-radius: 14px;
background: repeating-linear-gradient(-45deg, #1f1b2e 0 12px, #241f36 12px 24px);
color: #f0eaff;
font: 0.95rem/1.5 ui-monospace, 'SF Mono', Menlo, monospace;
}
button {
all: unset;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.5rem;
justify-self: start;
padding: 0.4rem 0.8rem;
border-radius: 999px;
background: #3b3355;
transition: background 150ms ease;
}
button:hover { background: #4a4170; }
button:focus-visible { outline: 2px solid #b69cff; outline-offset: 2px; }
.lock { transition: transform 300ms ease; }
.message {
margin: 0;
filter: blur(6px);
user-select: none;
transition: filter 400ms ease;
}
:host([revealed]) .message { filter: none; user-select: auto; }
:host([revealed]) .lock { transform: rotate(-20deg); }
`,
template,
css,
element: {
toggle: 'button',
hint: '.hint',

26
examples/star-rating.css Normal file
View File

@ -0,0 +1,26 @@
:host {
display: inline-flex;
align-items: center;
gap: 0.75rem;
font: 500 0.95rem/1 system-ui, sans-serif;
color: var(--ink, #1d1d29);
}
.stars { display: inline-flex; gap: 0.15rem; }
button {
all: unset;
cursor: pointer;
font-size: 1.75rem;
line-height: 1;
color: #d4d4dc;
transition: color 120ms ease, transform 120ms ease;
}
button:focus-visible { outline: 2px solid #f5a524; outline-offset: 2px; border-radius: 4px; }
button.on { color: #f5a524; }
button.preview { color: #f8c96b; }
:host(:not([readonly])) button:hover { transform: scale(1.2); }
:host([readonly]) button { cursor: default; }
output {
min-width: 3.5em;
font-variant-numeric: tabular-nums;
color: var(--muted, #6b6b7b);
}

View File

@ -0,0 +1,3 @@
<span class="label"><slot>Rating</slot></span>
<div class="stars" role="radiogroup"></div>
<output>0 / 5</output>

View File

@ -1,34 +1,7 @@
import { WebComponent, enable, number } from '../src/index.ts';
import { register } from './main-context.ts';
const css = `
:host {
display: inline-flex;
align-items: center;
gap: 0.75rem;
font: 500 0.95rem/1 system-ui, sans-serif;
color: var(--ink, #1d1d29);
}
.stars { display: inline-flex; gap: 0.15rem; }
button {
all: unset;
cursor: pointer;
font-size: 1.75rem;
line-height: 1;
color: #d4d4dc;
transition: color 120ms ease, transform 120ms ease;
}
button:focus-visible { outline: 2px solid #f5a524; outline-offset: 2px; border-radius: 4px; }
button.on { color: #f5a524; }
button.preview { color: #f8c96b; }
:host(:not([readonly])) button:hover { transform: scale(1.2); }
:host([readonly]) button { cursor: default; }
output {
min-width: 3.5em;
font-variant-numeric: tabular-nums;
color: var(--muted, #6b6b7b);
}
`;
import css from './star-rating.css' with { type: 'css' };
import template from './star-rating.html' with { type: 'text' };
/**
* Open shadow root built from `template`. The stars are rebuilt whenever `max`
@ -38,11 +11,7 @@ output {
const SPEC = {
shadow: 'open',
css,
template: `
<span class="label"><slot>Rating</slot></span>
<div class="stars" role="radiogroup"></div>
<output>0 / 5</output>
`,
template,
events: { click: true, pointerover: true, pointerleave: true },
element: { group: ['.stars', HTMLDivElement], output: 'output' },
elements: { stars: 'button.star' },

View File

@ -61,6 +61,13 @@ export default defineConfig({
'eslint/default-case': 'off',
},
},
{
// Ambient declaration files are scripts on purpose: no imports or exports.
files: ['**/*.d.ts'],
rules: {
'import/unambiguous': 'off',
},
},
],
env: {
builtin: true,

View File

@ -22,7 +22,7 @@
"LICENSE"
],
"scripts": {
"examples": "vite examples",
"examples": "vite",
"build": "rm -rf dist && tsc",
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
"lint": "oxlint --type-aware src test examples",

View File

@ -13,8 +13,12 @@ export interface Spec {
readonly shadow?: 'open' | 'closed';
/** Cloned into the shadow root, or appended to the light DOM on first connect. Ignored with declarative shadow DOM. */
readonly template?: string | HTMLTemplateElement;
/** Adopted into the shadow root, or added once per root (document `<head>` / enclosing shadow root) for light DOM. */
readonly css?: string;
/**
* Adopted into the shadow root, or added once per root for light DOM: a string becomes a `<style>`
* in `<head>` (or is adopted into an enclosing shadow root); a `CSSStyleSheet` (e.g. a CSS module
* import) is adopted as-is into the document or enclosing shadow root.
*/
readonly css?: string | CSSStyleSheet;
/** Prefix every *derived* attribute name with `data-`. Explicit `attribute`s are used verbatim. */
readonly dataPrefix?: boolean;
/**

View File

@ -62,7 +62,8 @@ export function WebComponent<const S extends Spec>(
}
let sheet: CSSStyleSheet | undefined;
function getSheet(css: string): CSSStyleSheet {
function getSheet(css: string | CSSStyleSheet): CSSStyleSheet {
if (typeof css !== 'string') return css;
if (!sheet) {
sheet = new CSSStyleSheet();
sheet.replaceSync(css);
@ -234,13 +235,13 @@ export function WebComponent<const S extends Spec>(
const rootNode = this.getRootNode();
if (styledRoots.has(rootNode)) return;
styledRoots.add(rootNode);
if (rootNode instanceof ShadowRoot) {
rootNode.adoptedStyleSheets = [...rootNode.adoptedStyleSheets, getSheet(css)];
} else {
if (typeof css === 'string' && !(rootNode instanceof ShadowRoot)) {
const style = document.createElement('style');
style.dataset['wcMixin'] = this.localName;
style.textContent = css;
document.head.append(style);
} else if (rootNode instanceof ShadowRoot || rootNode instanceof Document) {
rootNode.adoptedStyleSheets = [...rootNode.adoptedStyleSheets, getSheet(css)];
}
}

View File

@ -216,6 +216,36 @@ describe('light DOM', () => {
'rgb(255, 0, 0)',
);
});
test('a CSSStyleSheet is adopted into the document once per element type', () => {
const sheet = new CSSStyleSheet();
sheet.replaceSync('p { color: red; }');
class El extends WebComponent({ css: sheet, template: '<p>hi</p>' }) {}
const tag = nextTag();
customElements.define(tag, El);
const before = document.adoptedStyleSheets.length;
document.body.append(new El(), new El());
expect(document.adoptedStyleSheets).toHaveLength(before + 1);
expect(document.adoptedStyleSheets.at(-1)).toBe(sheet);
expect(document.head.querySelector(`style[data-wc-mixin="${tag}"]`)).toBeNull();
expect(
getComputedStyle(instance(HTMLParagraphElement, document.querySelector(`${tag} p`))).color,
).toBe('rgb(255, 0, 0)');
document.adoptedStyleSheets = document.adoptedStyleSheets.filter((s) => s !== sheet);
});
test('a CSSStyleSheet is adopted as-is into its own shadow root', () => {
const sheet = new CSSStyleSheet();
sheet.replaceSync('p { color: red; }');
class El extends WebComponent({ shadow: 'open', css: sheet, template: '<p>hi</p>' }) {}
customElements.define(nextTag(), El);
const el = new El();
document.body.append(el);
expect(el.shadowRoot?.adoptedStyleSheets).toEqual([sheet]);
expect(
getComputedStyle(instance(HTMLParagraphElement, el.shadowRoot?.querySelector('p'))).color,
).toBe('rgb(255, 0, 0)');
});
});
describe('signal and events', () => {

42
vite.config.ts Normal file
View File

@ -0,0 +1,42 @@
import { readFile } from 'node:fs/promises';
import { defineConfig, type Plugin } from 'vite';
const ATTRIBUTE_IMPORT =
/(from\s*)(['"])([^'"]+)\2\s*with\s*\{\s*type\s*:\s*(['"])(css|text)\4\s*\}/g;
/**
* Standard import attributes, built for browsers that don't support them yet (Safari):
*
* - `import sheet from './x.css' with { type: 'css' }` → a `CSSStyleSheet`
* - `import text from './x.html' with { type: 'text' }` → the file's contents as a string
*
* The attribute is rewritten to a query before Vite sees the import. `?raw` makes Vite's own CSS
* handling leave the file alone; `&sheet` is loaded here as a module that builds the stylesheet.
*/
function importAttributes(): Plugin {
return {
name: 'import-attributes',
enforce: 'pre',
transform(code, id) {
if (!/\.[cm]?[jt]s$/.test(id) || !code.includes('with')) return null;
const out = code.replace(
ATTRIBUTE_IMPORT,
(_, from: string, quote: string, path: string, _q: string, type: string) =>
`${from}${quote}${path}?raw${type === 'css' ? '&sheet' : ''}${quote}`,
);
return out === code ? null : { code: out, map: null };
},
async load(id) {
if (!id.endsWith('?raw&sheet')) return null;
const file = id.slice(0, -'?raw&sheet'.length);
this.addWatchFile(file);
const css = await readFile(file, 'utf8');
return `const sheet = new CSSStyleSheet();\nsheet.replaceSync(${JSON.stringify(css)});\nexport default sheet;\n`;
},
};
}
export default defineConfig({
root: 'examples',
plugins: [importAttributes()],
});