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.
102 lines
2.9 KiB
TypeScript
102 lines
2.9 KiB
TypeScript
import { WebComponent, enable, string, type PropChange } from '../src/index.ts';
|
|
import { register } from './main-context.ts';
|
|
|
|
/**
|
|
* Closed shadow root built from `template`: `el.shadowRoot` is `null` from the
|
|
* outside, but the component still reaches its own DOM through `this.root` and
|
|
* `this.refs`. Closed hides the internals, not the data; `message` is still a
|
|
* reflected attribute.
|
|
*/
|
|
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); }
|
|
`,
|
|
element: {
|
|
toggle: 'button',
|
|
hint: '.hint',
|
|
lock: '.lock',
|
|
message: 'p.message',
|
|
},
|
|
props: {
|
|
message: string({ default: '' }),
|
|
revealed: enable(),
|
|
},
|
|
} as const;
|
|
|
|
export class SecretNote extends WebComponent(SPEC) {
|
|
override onConnect(): void {
|
|
// Not `events: { click: true }`: a listener on the host can't see into a closed
|
|
// root (`composedPath()` stops at the host), so listen on the button itself.
|
|
this.refs.toggle?.addEventListener('click', this, { signal: this.signal });
|
|
this.#render();
|
|
register(this, {
|
|
onSecret: (secret) => {
|
|
this.message = secret;
|
|
},
|
|
});
|
|
}
|
|
|
|
override onPropChange(_change: PropChange<typeof SPEC>): void {
|
|
this.#render();
|
|
}
|
|
|
|
override handleEvent(): void {
|
|
this.revealed = !this.revealed;
|
|
}
|
|
|
|
#render(): void {
|
|
const { toggle, hint, lock, message } = this.refs;
|
|
if (message) message.textContent = this.message;
|
|
if (hint) hint.textContent = this.revealed ? 'Hide' : 'Click to reveal';
|
|
if (lock) lock.textContent = this.revealed ? '🔓' : '🔒';
|
|
toggle?.setAttribute('aria-expanded', String(this.revealed));
|
|
}
|
|
}
|
|
|
|
customElements.define('secret-note', SecretNote);
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'secret-note': SecretNote;
|
|
}
|
|
}
|