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:
parent
8865b49f4e
commit
76d78e1925
@ -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).
|
||||
|
||||
29
examples/color-swatch-picker.css
Normal file
29
examples/color-swatch-picker.css
Normal 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;
|
||||
}
|
||||
@ -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
50
examples/hero-banner.css
Normal 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; }
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -61,8 +61,8 @@
|
||||
<h2>Controls <span class="kind">light DOM</span></h2>
|
||||
<p>
|
||||
<code><labeled-field></code> and <code><color-swatch-picker></code> render
|
||||
into the light DOM; their CSS lives in a <code><style></code> in
|
||||
<code><head></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><main-context></code>, which calls the
|
||||
<code>on…</code> callbacks the other components registered.
|
||||
</p>
|
||||
|
||||
26
examples/labeled-field.css
Normal file
26
examples/labeled-field.css
Normal 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; }
|
||||
5
examples/labeled-field.html
Normal file
5
examples/labeled-field.html
Normal file
@ -0,0 +1,5 @@
|
||||
<label>
|
||||
<span class="field-label"></span>
|
||||
<input>
|
||||
</label>
|
||||
<small class="field-hint"></small>
|
||||
@ -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`.
|
||||
*/
|
||||
|
||||
1
examples/main-context.css
Normal file
1
examples/main-context.css
Normal file
@ -0,0 +1 @@
|
||||
main-context { display: contents; }
|
||||
@ -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
11
examples/modules.d.ts
vendored
Normal 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
32
examples/secret-note.css
Normal 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); }
|
||||
5
examples/secret-note.html
Normal file
5
examples/secret-note.html
Normal 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>
|
||||
@ -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
26
examples/star-rating.css
Normal 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);
|
||||
}
|
||||
3
examples/star-rating.html
Normal file
3
examples/star-rating.html
Normal file
@ -0,0 +1,3 @@
|
||||
<span class="label"><slot>Rating</slot></span>
|
||||
<div class="stars" role="radiogroup"></div>
|
||||
<output>0 / 5</output>
|
||||
@ -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' },
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
/**
|
||||
|
||||
@ -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)];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
42
vite.config.ts
Normal 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()],
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user