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.
124 lines
3.0 KiB
TypeScript
124 lines
3.0 KiB
TypeScript
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; }
|
|
}
|
|
`;
|
|
|
|
/**
|
|
* Declarative shadow DOM: the shadow root and its layout `<style>` are written
|
|
* in index.html inside `<template shadowrootmode="open">`, so the banner paints
|
|
* before any script runs. The mixin finds that root and uses it (no `shadow` or
|
|
* `template` here), and still adopts `css` into it for the animated effects.
|
|
*/
|
|
const SPEC = {
|
|
css,
|
|
element: {
|
|
headlineText: 'h1',
|
|
taglineText: 'p.tagline',
|
|
},
|
|
props: {
|
|
headline: string(),
|
|
tagline: string(),
|
|
theme: choice(THEMES, { default: 'sunset' }),
|
|
},
|
|
} as const;
|
|
|
|
export class HeroBanner extends WebComponent(SPEC) {
|
|
override onConnect(): void {
|
|
register(this, {
|
|
onHeadline: (headline) => {
|
|
this.headline = headline;
|
|
},
|
|
onTagline: (tagline) => {
|
|
this.tagline = tagline;
|
|
},
|
|
onTheme: (theme) => {
|
|
this.theme = theme;
|
|
},
|
|
});
|
|
}
|
|
|
|
override onPropChange(change: PropChange<typeof SPEC>): void {
|
|
switch (change.name) {
|
|
case 'headline': {
|
|
if (this.refs.headlineText) this.refs.headlineText.textContent = change.value ?? '';
|
|
break;
|
|
}
|
|
case 'tagline': {
|
|
const el = this.refs.taglineText;
|
|
if (!el) break;
|
|
el.textContent = change.value ?? '';
|
|
// Restart the entrance animation so each edit visibly lands.
|
|
for (const animation of el.getAnimations()) {
|
|
animation.cancel();
|
|
animation.play();
|
|
}
|
|
break;
|
|
}
|
|
case 'theme': {
|
|
// `:host([theme])` does the work; the attribute is already reflected.
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
customElements.define('hero-banner', HeroBanner);
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'hero-banner': HeroBanner;
|
|
}
|
|
}
|