Compare commits
87 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aafd99a39b | |||
| 2adc4e8d43 | |||
| 751067d367 | |||
| a9e5f920c0 | |||
| 283531b60b | |||
| d86fd259e9 | |||
| d65d54b70b | |||
| 54d62ff080 | |||
| d97f7762cd | |||
| da0663466c | |||
| 5b35fc67b3 | |||
| f2746123ae | |||
| bc896248a4 | |||
| acd1e97adf | |||
| f2c05fb7fb | |||
| da6ba502cd | |||
| 38f495c0fa | |||
| 44a2708a7c | |||
| ef45e03f0e | |||
| 63daf6e314 | |||
| 9690fa8f68 | |||
| cc879b3d0f | |||
| f6f9ab5e45 | |||
| 35b847105d | |||
| 530dff439c | |||
| 41b6d44259 | |||
| 8c3bcd1bec | |||
| 55405cac2c | |||
| 2e42382720 | |||
| 005db549c0 | |||
| 9d8c8879f8 | |||
| aad6525c3c | |||
| abe67055d0 | |||
| 55b96b4cf4 | |||
| c66a5eb7be | |||
| 7b48653966 | |||
| 4965231f6f | |||
| cef76dfd66 | |||
| b4ce4c8cf7 | |||
| 1bb352f893 | |||
| 46ad64d5aa | |||
| bbb07912bc | |||
| 772d656cfa | |||
| 56d13d2ce3 | |||
| f5adb74028 | |||
| 2997caddde | |||
| cdfe928112 | |||
| 975b045a0a | |||
| 20e31e4796 | |||
| 4b4cbc6cc8 | |||
| 5fc24633f5 | |||
| 37552b4d65 | |||
| d230d962ec | |||
| 50a21b817d | |||
| e3fb0254c7 | |||
| 561294f72c | |||
| b26cb30b9b | |||
| f290e049bd | |||
| 1b3e251a9d | |||
| 136343962d | |||
| c7d87086a2 | |||
| 56cb1de7db | |||
| 93e4248359 | |||
| be9c6b7cf4 | |||
| fa8d1df36e | |||
| 27377746ef | |||
| 133881a127 | |||
| 2da6cd5afc | |||
| 861310c6bf | |||
| 737ef54763 | |||
| 5635e6eb4a | |||
| a83b4893ab | |||
| a2c232a3cb | |||
| 16b1bf00b4 | |||
| 3a97b6356c | |||
| 8d5ad928e8 | |||
| 231c62531b | |||
| e62d613203 | |||
| e0ec0fcb7c | |||
| b0e48e404d | |||
| f320e845bb | |||
| b5b7008abf | |||
| 02ca084197 | |||
| 6af7598973 | |||
| ef20daa140 | |||
| ce30b114a2 | |||
| 038674a74e |
163 changed files with 11380 additions and 500 deletions
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "demo",
|
||||||
|
"runtimeExecutable": "pnpm",
|
||||||
|
"runtimeArgs": ["-F", "demo", "dev", "--port", "5199", "--strictPort"],
|
||||||
|
"port": 5199
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
135
.claude/skills/docs-review/SKILL.md
Normal file
135
.claude/skills/docs-review/SKILL.md
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
---
|
||||||
|
name: docs-review
|
||||||
|
description: Review documentation and give structured, actionable feedback grounded in the "50 docs tips" best practices from Sarah Rainsberger, Astro's docs lead. Use this whenever the user asks to review, critique, assess, or improve documentation of any kind — a README, tutorial, guide, API reference, changelog, release notes, docs site page, or Markdown/MDX file — whether they provide a file path, paste the text, or give a URL to a web page. Trigger even for casual phrasings like "look at these docs", "any feedback on this page?", "is this README good?", or "how can this guide be better?".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Docs Review
|
||||||
|
|
||||||
|
Review a piece of documentation and deliver feedback its author can act on
|
||||||
|
immediately. The review standard comes from Sarah Rainsberger's
|
||||||
|
["50 docs tips"](https://www.rainsberger.ca/docs-tips/) series, distilled into
|
||||||
|
`references/docs-tips.md`. The guiding idea of that series: the sole purpose of
|
||||||
|
docs is to be helpful. Every finding you report should trace back to a way the
|
||||||
|
document could help its reader more.
|
||||||
|
|
||||||
|
## Step 1: Get the document
|
||||||
|
|
||||||
|
- **Local file path** → `Read` it. If given a directory, list it and review the
|
||||||
|
entry-point docs first (README, index, getting-started), then ask which others
|
||||||
|
matter or sweep them if the user asked for everything.
|
||||||
|
- **URL** → `WebFetch` with a prompt asking for the page's full content,
|
||||||
|
including headings hierarchy, code blocks (with their titles/filenames, if
|
||||||
|
shown), link text, and admonitions/asides — these details are exactly what the
|
||||||
|
review needs, and a summary would hide them. If `WebFetch` is unavailable or
|
||||||
|
blocked, try `curl -sL <url>` and read the HTML. If the URL is unreachable
|
||||||
|
from the environment, say so plainly and ask the user to paste the content —
|
||||||
|
do not review a page you could not read.
|
||||||
|
- **Pasted text** → review it as-is.
|
||||||
|
|
||||||
|
## Step 2: Establish context before judging
|
||||||
|
|
||||||
|
Identify, and state in the review:
|
||||||
|
|
||||||
|
1. **Document type**: tutorial (learning-oriented), how-to guide
|
||||||
|
(task-oriented), reference (information-oriented), concept/explanation,
|
||||||
|
README, changelog/release notes, or landing page.
|
||||||
|
2. **Assumed audience**: beginner or experienced? Users of the project or
|
||||||
|
contributors to it?
|
||||||
|
3. **Place in the reader's journey**: what has the reader likely already read or
|
||||||
|
done when they land here?
|
||||||
|
|
||||||
|
This matters because several checks depend on purpose: content that is perfect
|
||||||
|
in a tutorial can be wrong in a reference page ("right words, wrong place"), and
|
||||||
|
release notes have their own rules. Judge the document against *its* job, not
|
||||||
|
against a generic ideal.
|
||||||
|
|
||||||
|
Also look for a project style guide (CONTRIBUTING, STYLEGUIDE, writing-guide
|
||||||
|
files) when reviewing docs inside a repo. Where the project's stated conventions
|
||||||
|
conflict with the rubric, the project wins — note the conflict rather than
|
||||||
|
flagging compliant text.
|
||||||
|
|
||||||
|
## Step 3: Review against the rubric
|
||||||
|
|
||||||
|
Read `references/docs-tips.md` in full, then go through the document with its
|
||||||
|
checklist categories:
|
||||||
|
|
||||||
|
1. Helpfulness and truth
|
||||||
|
2. Voice and tone
|
||||||
|
3. Word choice
|
||||||
|
4. Tense and current state
|
||||||
|
5. Positive patterns (show what to do)
|
||||||
|
6. Structure and the reader's journey
|
||||||
|
7. Instructions and procedures
|
||||||
|
8. Code samples
|
||||||
|
9. Scope (what not to document)
|
||||||
|
10. Changelogs and release notes (when applicable)
|
||||||
|
|
||||||
|
Collect concrete findings: quote the actual text, note where it is (heading,
|
||||||
|
section, or line), and identify which principle it violates. If the same issue
|
||||||
|
repeats (e.g. "simply" appears 14 times, or no code block has a title), report
|
||||||
|
it once as a pattern with a count and a couple of examples — don't list every
|
||||||
|
instance.
|
||||||
|
|
||||||
|
## Step 4: Write the review
|
||||||
|
|
||||||
|
Use this structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Docs review: <title or path>
|
||||||
|
|
||||||
|
**Type:** <doc type> · **Audience:** <assumed audience>
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
<2–4 sentences: what the document does well and the single biggest opportunity.>
|
||||||
|
|
||||||
|
## What's working
|
||||||
|
<3–5 bullets. Real strengths, specifically observed — not padding.>
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### High impact — hurts the reader's success
|
||||||
|
### Medium — causes friction or confusion
|
||||||
|
### Polish — small wins
|
||||||
|
|
||||||
|
<Each finding:>
|
||||||
|
- **Where:** <section / heading / line>
|
||||||
|
**What:** "<short quote of the current text>"
|
||||||
|
**Why it matters:** <one sentence, citing the tip — e.g. (Tip 23: is not was)>
|
||||||
|
**Try instead:** "<concrete rewritten text, ready to paste>"
|
||||||
|
|
||||||
|
## Quick wins
|
||||||
|
<Top 3–5 changes with the best effort-to-impact ratio.>
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules of conduct for the review itself — the rubric applies to your feedback
|
||||||
|
too:
|
||||||
|
|
||||||
|
- **Show what to do** (Tip 34): never flag something without showing a fixed
|
||||||
|
version. A criticism without a rewrite makes the author do your job.
|
||||||
|
- **Prioritize by reader impact**, not by how easy something is to spot. A
|
||||||
|
broken prerequisite chain outranks ten filler words.
|
||||||
|
- **Be selective.** A review with 8 well-chosen findings gets acted on; one
|
||||||
|
with 40 gets closed. Fold minor repeats into patterns, and let genuinely fine
|
||||||
|
text be fine.
|
||||||
|
- **NWTWWHB** (Tip 49): when reviewing a contribution or a draft, the bar is
|
||||||
|
"not worse than what we had before" — improvements need not be perfect to be
|
||||||
|
worth merging. Frame feedback so the author can land the improvement now and
|
||||||
|
polish later.
|
||||||
|
- **Don't review taste.** If the project consistently uses a voice or
|
||||||
|
convention you wouldn't choose, consistency wins; flag only real barriers to
|
||||||
|
the reader.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- **Very long documents** (roughly 1,500+ lines): review the structure first —
|
||||||
|
headings map, ordering, findability — then deep-review the highest-traffic
|
||||||
|
sections (intro, install, first task). State explicitly which sections got a
|
||||||
|
deep read and which were sampled.
|
||||||
|
- **Multiple files**: give each file a short review plus one shared section for
|
||||||
|
patterns that repeat across the set.
|
||||||
|
- **"Fix it" requests**: if the user asked you to improve the docs rather than
|
||||||
|
just review them, still produce the findings list first (so the reasoning is
|
||||||
|
visible), then apply the edits for everything you flagged.
|
||||||
|
- **Non-docs text** (marketing copy, blog posts): the rubric mostly still
|
||||||
|
applies, but say you're adapting a docs rubric and skip docs-specific checks
|
||||||
|
(code block titles, changelog rules) that don't fit.
|
||||||
254
.claude/skills/docs-review/references/docs-tips.md
Normal file
254
.claude/skills/docs-review/references/docs-tips.md
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
# Docs review rubric — distilled from Sarah Rainsberger's "50 docs tips"
|
||||||
|
|
||||||
|
Source: [50 docs tips](https://www.rainsberger.ca/docs-tips/) by Sarah
|
||||||
|
Rainsberger, docs lead for [Astro](https://astro.build), written as a
|
||||||
|
"50 docs tips in 50 days" series, plus her companion talk
|
||||||
|
["Stop writing docs. Start helping!"](https://www.rainsberger.ca/blog/stop-writing-start-helping/).
|
||||||
|
|
||||||
|
This file distills the series into a review rubric organized by theme. Where a
|
||||||
|
check comes from a specific tip, it is cited by number and title. Entries marked
|
||||||
|
*(from title)* are interpreted from the tip's (self-demonstrating) title; the
|
||||||
|
canonical wording lives on the site. The rubric is faithful to the series'
|
||||||
|
philosophy but is a distillation, not a mirror.
|
||||||
|
|
||||||
|
**The one-line philosophy:** the sole purpose of documentation is to be
|
||||||
|
helpful. "Minimum Viable Docs" = every statement is true, and readers can find,
|
||||||
|
read, and understand what the statements mean. Every check below is a
|
||||||
|
concrete way docs fail one of those conditions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Helpfulness and truth
|
||||||
|
|
||||||
|
- **Every statement must be true — docs are the record of truth** (Tip 14:
|
||||||
|
*this post includes advice*). Official docs are the primary resource for how
|
||||||
|
a project works; readers come to docs for the truth they can't get anywhere
|
||||||
|
else. Flag anything speculative, outdated, or contradicted elsewhere in the
|
||||||
|
same docs. Precision extends to small words: "includes" promises a partial
|
||||||
|
list; "are" promises a complete one. Check that the word's promise matches
|
||||||
|
reality.
|
||||||
|
- **Helping beats writing.** If a sentence exists to sound complete rather than
|
||||||
|
to help the reader do or understand something, it's a candidate for deletion.
|
||||||
|
If you want readers to do something to have a successful outcome, just say it
|
||||||
|
that way — as a direct instruction, not a description of possibilities.
|
||||||
|
- **Watch for "should."** "The value should be a string" — is it a requirement,
|
||||||
|
a recommendation, or a hope? Replace with the true statement: "The value must
|
||||||
|
be a string" / "Set the value to a string". Deleting or replacing "should"
|
||||||
|
almost always makes a docs sentence more true.
|
||||||
|
|
||||||
|
## 2. Voice and tone
|
||||||
|
|
||||||
|
- **Don't sound smart; make your reader feel smart** (Tip 1). Flag prose whose
|
||||||
|
main effect is demonstrating the author's expertise — dense jargon, showing
|
||||||
|
off internals, name-dropping theory before the reader can do anything with it.
|
||||||
|
- **Docs is not a conversation — skip the pleasantries** (Tip 3). "Welcome!",
|
||||||
|
"Now, let's dive in!", "Happy coding!", "As you may recall…" cost reading time
|
||||||
|
and carry no information. Cut them. (A single short greeting on a landing page
|
||||||
|
can be fine; a chatty tone *throughout task content* is the problem.)
|
||||||
|
- **Simplify so the meaningful words stand out** (Tip 39: *smarty pants*).
|
||||||
|
Docs must be consumable by readers of every experience and language level.
|
||||||
|
Prefer the plain word over the impressive one: "use" over "leverage,"
|
||||||
|
"so" over "consequently." No one has ever complained that docs were too easy
|
||||||
|
to read.
|
||||||
|
- **Don't say "please"** (Tip 41: *if you please*, from title). Instructions
|
||||||
|
are not requests: "please run the following command" → "Run the following
|
||||||
|
command." Politeness words dilute imperatives and read oddly at scale.
|
||||||
|
- **Trust your reader** (Tip 8: *don't forget*). "Don't forget to…" /
|
||||||
|
"Remember to…" assumes the reader is careless. State the step; trust them to
|
||||||
|
scroll up if something didn't work the first time.
|
||||||
|
- **Don't hedge required steps as optional** (Tip 16: *read this tip… if you
|
||||||
|
want to*, from title). "If you want, you can add…" — if the step is needed
|
||||||
|
for success, instruct it. Reserve "optionally" for things that are truly
|
||||||
|
optional, and say what choosing the option gets you.
|
||||||
|
|
||||||
|
## 3. Word choice
|
||||||
|
|
||||||
|
- **Say exactly what happens — don't "make things happen"** (Tip 2). Vague
|
||||||
|
cause-and-effect phrasing ("this makes the component work") hides the actual
|
||||||
|
behavior. Name the effect: "this registers the component so the browser
|
||||||
|
renders it wherever the tag appears."
|
||||||
|
- **Say how a thing is used, not just "use"** (Tip 26: *use this*). "Use" often
|
||||||
|
obscures the real purpose or action. If someone handed you a hammer and said
|
||||||
|
"Use this!", you'd have a reasonable idea what they meant — your API is not a
|
||||||
|
hammer. "Use the `render` prop" → "Pass a function to the `render` prop to
|
||||||
|
control what each row displays."
|
||||||
|
- **Ban "appropriate," "as needed," "properly"** (Tip 13: *inadvertently
|
||||||
|
inappropriate*). "Configure the appropriate values" is not descriptive — the
|
||||||
|
reader came to the docs precisely because they don't know which values are
|
||||||
|
appropriate. Replace with the actual criteria or an example.
|
||||||
|
- **Define even the "obvious" terms** (Tip 32: *defining the obvious*). Don't
|
||||||
|
rely on the common meaning of a word to explain what it means in your
|
||||||
|
project. Docs serve readers of all experience levels and language levels; a
|
||||||
|
term of art ("island", "hydration", "slot") needs a definition or a link at
|
||||||
|
first use.
|
||||||
|
- **Minimize filler and intensifiers.** "Simply," "just," "easily," "obviously,"
|
||||||
|
"of course" — these claim the task is easy instead of making it easy, and
|
||||||
|
they read as condescending the moment a reader struggles. (Flag as a pattern
|
||||||
|
with a count, not one finding per instance.)
|
||||||
|
|
||||||
|
## 4. Tense and current state
|
||||||
|
|
||||||
|
- **Is, not was** (Tip 23). Document the present state of the project in
|
||||||
|
present tense. Readers don't need the history ("previously this option was
|
||||||
|
called…", "as of v2 you can now…") to use the current version. Historical
|
||||||
|
notes belong in the changelog; "now" ages badly everywhere.
|
||||||
|
- **New features get assimilated, not appended** (Tip 21: *prepare to be
|
||||||
|
assimilated*). Much docs work isn't writing new pages — it's updating
|
||||||
|
existing ones. When a feature gains a new option, the existing sections that
|
||||||
|
are now incomplete or inconsistent must be updated too. In review: check
|
||||||
|
whether the surrounding/related sections still agree with the text under
|
||||||
|
review; flag "NEW in vX.Y"-style bolt-on paragraphs that should be merged
|
||||||
|
into the main flow.
|
||||||
|
|
||||||
|
## 5. Positive patterns — show what to do
|
||||||
|
|
||||||
|
- **Show the good version** (Tip 34: *show what to do*). Present things in the
|
||||||
|
positive: this is how it works, this is how you do it. Avoid teaching via
|
||||||
|
anti-examples — readers skim, copy code blocks, and remember what they saw,
|
||||||
|
so a "❌ don't do this" block can inadvertently introduce or reinforce the
|
||||||
|
unwanted pattern. If a warning is truly needed, keep the bad example small,
|
||||||
|
clearly marked, and always paired with the good version.
|
||||||
|
- **Don't reference what isn't shown** (Tip 31: *as not shown here*, from
|
||||||
|
title). "As shown above," "as mentioned earlier," "see the previous example" —
|
||||||
|
verify the referenced thing actually exists, is nearby, and is where the
|
||||||
|
text claims. Better: link directly to it or restate the one line that
|
||||||
|
matters.
|
||||||
|
|
||||||
|
## 6. Structure and the reader's journey
|
||||||
|
|
||||||
|
- **Make it a heading** (Tip 4). If readers need to find something, make it a
|
||||||
|
heading — headings are what scanning eyes and link anchors can hit. Check:
|
||||||
|
are the questions readers bring to this page answered under headings that
|
||||||
|
name them? Are headings specific ("Deploy to Netlify") rather than clever or
|
||||||
|
vague ("Going live!")? Link to the most specific anchor possible, not to the
|
||||||
|
top of a long page.
|
||||||
|
- **Right words, wrong place** (Tip 37). Writing can be good *and* misplaced.
|
||||||
|
Judge content against the page's purpose and the reader's position in their
|
||||||
|
journey: conceptual background in the middle of a task sequence, or install
|
||||||
|
instructions on a reference page, may need moving, not rewriting. The heavy
|
||||||
|
lifting of structure is the author's job, not the reader's.
|
||||||
|
- **Mise en place — prerequisites up front** (Tip 24). A guide should open by
|
||||||
|
listing what the reader needs ready (account created, project configured,
|
||||||
|
token in hand), so they aren't "baking fudge in the middle of making the ice
|
||||||
|
cream it goes into." Mid-task detours to other tools or pages are a smell:
|
||||||
|
move them into a prerequisites list at the top.
|
||||||
|
- **Don't re-document what's documented elsewhere** (Tip 24 again). Link to the
|
||||||
|
canonical instructions (yours or a third party's) instead of restating them.
|
||||||
|
Restated copies drift: when the other tool changes its settings menu, your
|
||||||
|
guide breaks without you knowing. Prerequisites + links keep the maintenance
|
||||||
|
burden where it belongs.
|
||||||
|
- **A page must stand on its own.** Readers arrive from search engines and
|
||||||
|
deep links, not from page one. Check the page works for someone who landed
|
||||||
|
here first: terms defined or linked, prerequisites stated, no reliance on
|
||||||
|
context from "the previous page."
|
||||||
|
|
||||||
|
## 7. Instructions and procedures
|
||||||
|
|
||||||
|
- **Twist, then pull** (Tip 18: *twist \*then\* pull*, from title). Write steps
|
||||||
|
in the order the reader performs them. "Pull the lever after twisting the
|
||||||
|
cap" forces the reader to buffer and reorder; "Twist the cap, then pull the
|
||||||
|
lever" doesn't. Within a step, put the condition or location before the
|
||||||
|
action: "In `astro.config.mjs`, add…" beats "Add… in `astro.config.mjs`."
|
||||||
|
- **One action per step; a real sequence per procedure** (Tip 22: *and then*,
|
||||||
|
from title). Long "and then… and then…" chains hide steps inside prose.
|
||||||
|
Numbered steps, each with one action and its expected outcome, let readers
|
||||||
|
keep their place and notice when something went wrong.
|
||||||
|
- **Say where code goes and what it does** (Tip 20: *add this code*, from
|
||||||
|
title). "Add this code:" followed by a block is not an instruction. Name the
|
||||||
|
file, the location in it, and what the addition accomplishes — then show the
|
||||||
|
code.
|
||||||
|
|
||||||
|
## 8. Code samples
|
||||||
|
|
||||||
|
- **Title your code blocks** (Tip 7). Add the file name as the code block's
|
||||||
|
title so readers know where the code lives (and which of several files a
|
||||||
|
multi-block sequence is editing). In Markdown/MDX, that's the fence title
|
||||||
|
(```` ```js title="src/components/Card.js" ````) or a lead-in line naming the
|
||||||
|
file.
|
||||||
|
- **Samples are the most-copied part of the page.** They get pasted verbatim,
|
||||||
|
so they must be correct, complete enough to run, and show the pattern you
|
||||||
|
want reproduced (see Tip 34). Placeholder values should be unmistakably
|
||||||
|
placeholders (`YOUR_API_KEY`, not `abc123`).
|
||||||
|
- **Keep prose and code in sync.** If the paragraph says the option is called
|
||||||
|
`retries` and the block shows `retryCount`, one of them is lying (Tip 14).
|
||||||
|
|
||||||
|
## 9. Scope — what not to document
|
||||||
|
|
||||||
|
- **Don't doc when you can fix** (Tip 15). Don't document workarounds, confusing
|
||||||
|
defaults, or error-prone steps — fix them (or file the issue and link it).
|
||||||
|
In review: a paragraph explaining how to work around the product is a signal
|
||||||
|
to escalate to the maintainers, not to polish the paragraph.
|
||||||
|
- **What problem does this solve?** (Tip 33: *what's your problem?*, from
|
||||||
|
title). Features should be introduced in terms of the reader's problem or
|
||||||
|
goal, not as an inventory of what exists. If a section can't say why a reader
|
||||||
|
would want the thing, it isn't done.
|
||||||
|
- **Own the problems your project creates** (Tip 36: *not my problem* —
|
||||||
|
"Creates and solves problems. Sometimes, in that order."). Where your
|
||||||
|
project's choices create work for the reader (breaking changes, sharp edges,
|
||||||
|
integration quirks), the docs should acknowledge and address them rather than
|
||||||
|
staying silent because the fix lives in someone else's tool.
|
||||||
|
|
||||||
|
## 10. Changelogs and release notes
|
||||||
|
|
||||||
|
- **Release notes are docs too** (Tip 19: *be the change you want to see*).
|
||||||
|
"Bug fixes and improvements" announces that something changed; readers need
|
||||||
|
to know what is *different for them*. Describe each change from the reader's
|
||||||
|
perspective — a person who uses the project, not someone who works on it.
|
||||||
|
They care about changes required in *their* code, not changes in yours.
|
||||||
|
"Refactored the resolver" → "Imports with a trailing slash now resolve to the
|
||||||
|
directory's index file. If you relied on X, do Y."
|
||||||
|
|
||||||
|
## 11. Reviewing docs contributions
|
||||||
|
|
||||||
|
- **NWTWWHB — "not worse than what we had before"** (Tip 49). An inclusive
|
||||||
|
standard for community docs: a contribution that makes the docs better —
|
||||||
|
truer, clearer, more complete — is mergeable even if it isn't perfect.
|
||||||
|
Perfect-or-nothing review standards stall docs and exclude contributors.
|
||||||
|
Apply this when reviewing PRs and drafts: separate "must fix before merge"
|
||||||
|
(untruths, broken instructions) from "could polish later" (style, structure),
|
||||||
|
and say which is which.
|
||||||
|
- **Be the change** (Tip 19, spirit of): when you know the fix, propose the
|
||||||
|
exact wording (suggestion blocks in PRs, "Try instead:" in reviews) rather
|
||||||
|
than describing the fix abstractly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Confirmed tip index
|
||||||
|
|
||||||
|
Tips verified against the source (title, and content where captured). The
|
||||||
|
canonical text for every tip is at
|
||||||
|
`https://www.rainsberger.ca/docs-tips/`.
|
||||||
|
|
||||||
|
| # | Title | Gist |
|
||||||
|
|----|-------|------|
|
||||||
|
| 1 | don't sound smart | Make your reader feel smart instead |
|
||||||
|
| 2 | make things happen | Say precisely what happens; avoid vague "makes X happen" |
|
||||||
|
| 3 | skip the pleasantries | Docs is not a conversation |
|
||||||
|
| 4 | make it a heading | Findable content is a (deep-linkable) heading |
|
||||||
|
| 7 | title your code blocks | Add file names as code block titles |
|
||||||
|
| 8 | don't forget | Trust the reader; drop "don't forget to…" |
|
||||||
|
| 13 | inadvertently inappropriate | Remove the word "appropriate" — it describes nothing |
|
||||||
|
| 14 | this post includes advice | Docs are the truth; word-level precision matters |
|
||||||
|
| 15 | don't doc when you can fix | Don't document workarounds — fix them |
|
||||||
|
| 16 | read this tip… if you want to | *(from title)* Don't hedge needed steps as optional |
|
||||||
|
| 18 | twist *then* pull | *(from title)* Steps in the order they're performed |
|
||||||
|
| 19 | be the change you want to see | Release notes from the reader's perspective |
|
||||||
|
| 20 | add this code | *(from title)* Say where code goes and why |
|
||||||
|
| 21 | prepare to be assimilated | Update existing sections when features change |
|
||||||
|
| 22 | and then | *(from title)* Real sequences, not prose chains |
|
||||||
|
| 23 | is not was | Present tense; document current state |
|
||||||
|
| 24 | mise en place | Prerequisites up front; don't re-document |
|
||||||
|
| 26 | use this | Replace vague "use" with the actual action |
|
||||||
|
| 31 | as not shown here | *(from title)* Don't reference content that isn't there |
|
||||||
|
| 32 | defining the obvious | Define terms; serve all language levels |
|
||||||
|
| 33 | what's your problem? | *(from title)* Frame features by the problem they solve |
|
||||||
|
| 34 | show what to do | Positive examples; don't teach anti-patterns |
|
||||||
|
| 36 | not my problem | "Creates and solves problems. Sometimes, in that order." |
|
||||||
|
| 37 | right words, wrong place | Placement along the reader journey matters |
|
||||||
|
| 39 | smarty pants | Simplify so meaningful words stand out |
|
||||||
|
| 41 | if you please | *(from title)* Drop "please" from instructions |
|
||||||
|
| 49 | NWTWWHB | "Not worse than what we had before" is an inclusive merge bar |
|
||||||
|
|
||||||
|
The remaining tips in the series continue the same themes; when network access
|
||||||
|
allows, fetch `https://www.rainsberger.ca/docs-tips/` for the full canonical
|
||||||
|
list before citing a tip number not in this table.
|
||||||
122
.claude/skills/update-docs-numbers/SKILL.md
Normal file
122
.claude/skills/update-docs-numbers/SKILL.md
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
---
|
||||||
|
name: update-docs-numbers
|
||||||
|
description: Re-measure the library-size benchmark and update every size/version number in the docs and demo to the latest results. Use whenever the user asks to refresh, update, or re-measure the comparison numbers, the size figures in the docs, or the "why/comparison" guides — e.g. after bumping a pinned library version in demo/package.json, after a base-class size change lands in size-change-log.md, or before a release. Trigger on phrasings like "update the docs numbers", "re-run the size comparison", "the comparison table is stale", "refresh the library sizes".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Update docs numbers
|
||||||
|
|
||||||
|
The size and version numbers quoted across the docs and demo are **measured**,
|
||||||
|
not hand-written, and they drift the moment a pinned library version bumps or a
|
||||||
|
base-class size change lands. This skill regenerates the numbers from their two
|
||||||
|
sources of truth and propagates them to every place that quotes them, so the
|
||||||
|
docs, the demo, and the benchmark never disagree.
|
||||||
|
|
||||||
|
Do the whole sweep in one pass — a partial update (e.g. the guide table but not
|
||||||
|
the demo table) is worse than none, because it hides the drift.
|
||||||
|
|
||||||
|
## The two sources of truth — don't conflate them
|
||||||
|
|
||||||
|
There are **two independent numbers**, measured different ways. Mixing them up
|
||||||
|
is the classic mistake:
|
||||||
|
|
||||||
|
1. **The comparison table** — "cost of your first component": each library's
|
||||||
|
runtime **plus** a minimal counter, bundled and compressed. Produced by
|
||||||
|
`demo/examples/library-comparison/measure.mjs`. The WCB row here is ~**2.6 kB
|
||||||
|
brotli**. This drives the guide's comparison table, the percentages, and the
|
||||||
|
demo page.
|
||||||
|
2. **The base-class size** — just `WebComponent` on its own, min + brotli via
|
||||||
|
`size-limit`. This is ~**1.98 kB** and lives in `size-change-log.md` (latest
|
||||||
|
row) and is the headline in `library-size.md`. It is **not** the 2.6 kB
|
||||||
|
counter number.
|
||||||
|
|
||||||
|
`library-size.md` uses source 2. Everything else uses source 1. Never copy 2.6
|
||||||
|
into the base-class line or 1.98 into the comparison table.
|
||||||
|
|
||||||
|
## Step 1 — regenerate source 1 (the benchmark)
|
||||||
|
|
||||||
|
The benchmark bundles WCB from the published `dist/`, so build first:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm build # from repo root — refreshes dist/
|
||||||
|
cd demo/examples/library-comparison
|
||||||
|
node measure.mjs --json # raw bytes — use for percentages
|
||||||
|
node measure.mjs --md # Markdown table — what the guide embeds
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--json` gives exact byte counts. **Compute all percentages/ratios from these
|
||||||
|
raw bytes, not from the rounded kB**, or the rounding won't match (e.g. WCB
|
||||||
|
vs Elena reads ~21% from bytes but ~24% from rounded 2.6/3.4 kB).
|
||||||
|
- `--md` gives the table body the guide uses; the kB values also feed the demo.
|
||||||
|
- The library versions in the output come from the pinned deps in
|
||||||
|
`demo/package.json` (and WCB's own `package.json`). If the user's goal was to
|
||||||
|
pick up a new library release, bump that pin, re-run `pnpm --filter demo install`,
|
||||||
|
then re-measure.
|
||||||
|
|
||||||
|
## Step 2 — regenerate source 2 (the base-class size)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm size-limit # from repo root; the WebComponent.js row is min+brotli
|
||||||
|
```
|
||||||
|
|
||||||
|
The authoritative figure for prose is the **latest row of `size-change-log.md`**
|
||||||
|
(its min+brotli column). Per AGENTS.md, `library-size.md` must match that row
|
||||||
|
exactly. If a size change just landed but no log row was added, that is a
|
||||||
|
separate omission — flag it; don't invent a number.
|
||||||
|
|
||||||
|
## Step 3 — compute the derived figures from raw brotli bytes
|
||||||
|
|
||||||
|
From the `--json` brotli byte counts (`wcb`, `elena`, `lit`, `fast`):
|
||||||
|
|
||||||
|
- **"N% smaller than X"** = `round((X_brotli - wcb_brotli) / X_brotli * 100)`.
|
||||||
|
- **"Lit and FAST cost 2x–Nx more"** = `lit_brotli / wcb_brotli` … `fast_brotli / wcb_brotli`,
|
||||||
|
rounded to one decimal (low end floored to `2x`-style, high end e.g. `4.7x`).
|
||||||
|
- **Feature-comparison version labels** use `major.minor` of each measured
|
||||||
|
version (e.g. `6.1.4` → "WCB 6.1", `3.3.3` → "Lit 3.3").
|
||||||
|
|
||||||
|
## Step 4 — propagate to every location
|
||||||
|
|
||||||
|
Update all of these. The checklist is the point — each holds a copy that drifts.
|
||||||
|
|
||||||
|
**`docs/src/content/docs/guides/comparison.md`**
|
||||||
|
- The **measured size table** — every library's Version / Minified / Gzip /
|
||||||
|
Brotli (from `--md`).
|
||||||
|
- The **"~X% smaller than Elena, …"** sentence under it (Step 3).
|
||||||
|
- The **feature-comparison table header** version labels (`WCB 6.1`, `Lit 3.3`,
|
||||||
|
`Elena 1.0`, `FAST 3.0`) — `major.minor` of the measured versions.
|
||||||
|
- The **footer methodology line** — dates and the WCB version measured. Set the
|
||||||
|
WCB date to today (`date +%Y-%m-%d`); only change the "others measured" date if
|
||||||
|
the external pins actually changed this run.
|
||||||
|
|
||||||
|
**`docs/src/content/docs/guides/why.md`**
|
||||||
|
- The **"Lit and FAST cost 2x–Nx more"** ratio (Step 3).
|
||||||
|
- The **"runtime is 2.6 kB brotli-compressed"** figure — this is the WCB
|
||||||
|
**counter** brotli from source 1, and it appears **twice** (reasons 1 and 2).
|
||||||
|
|
||||||
|
**`docs/src/content/docs/guides/library-size.md`**
|
||||||
|
- The **base-class "N kB (min + brotli)"** headline — source 2, matching the
|
||||||
|
latest `size-change-log.md` row.
|
||||||
|
|
||||||
|
**`demo/examples/library-comparison/index.html`**
|
||||||
|
- The five **counter-card `<span class="size">`** brotli values.
|
||||||
|
- The **Output `<table class="sizes">`** rows — WCB version + every
|
||||||
|
Minified/Gzip/Brotli cell (must equal the guide's table).
|
||||||
|
- The **"about X% under Elena, Y% under Lit, Z% under FAST"** sentence.
|
||||||
|
|
||||||
|
`measure.mjs` and the counter sources under `counters/` are the generators — do
|
||||||
|
**not** hand-edit their numbers.
|
||||||
|
|
||||||
|
## Step 5 — verify no stale copy survives
|
||||||
|
|
||||||
|
Sweep for leftover old values so no copy is missed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /home/dev/Projects/wcb
|
||||||
|
# old WCB counter/base numbers, old versions — replace the search terms with
|
||||||
|
# whatever the PREVIOUS values were:
|
||||||
|
grep -rn "6\.6 kB\|6\.7 kB\|<old-version>\|<old-percent>%" docs/src demo/examples/library-comparison
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the guide's size table and the demo's Output table are cell-for-cell
|
||||||
|
identical, and that the WCB counter brotli (source 1) and the base-class figure
|
||||||
|
(source 2) each appear only where they belong. Note anything you changed and the
|
||||||
|
before→after for the headline numbers in your summary to the user.
|
||||||
45
.github/workflows/nightly.yml
vendored
Normal file
45
.github/workflows/nightly.yml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
name: Nightly E2E
|
||||||
|
|
||||||
|
# The full cross-browser e2e matrix (Chromium + Firefox + WebKit) is heavy to
|
||||||
|
# set up (browser downloads + system deps), so it runs once a day instead of on
|
||||||
|
# every push/PR. Push/PR CI keeps a fast Chromium-only e2e gate (test.yml).
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# 06:00 UTC daily
|
||||||
|
- cron: '0 6 * * *'
|
||||||
|
workflow_dispatch: # allow manual runs from the Actions tab
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e-all:
|
||||||
|
name: E2E (Chromium + Firefox + WebKit)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
# Firefox/WebKit need system deps that `--with-deps` installs via apt.
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: pnpm exec playwright install --with-deps chromium firefox webkit
|
||||||
|
- run: pnpm test:e2e:all
|
||||||
|
|
||||||
|
size:
|
||||||
|
name: Size limit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
# `pnpm size-limit` builds `dist/` (tsc + esbuild) and checks each entry
|
||||||
|
# against the byte budgets declared in package.json `size-limit`.
|
||||||
|
- run: pnpm size-limit
|
||||||
42
.github/workflows/release.yml
vendored
42
.github/workflows/release.yml
vendored
|
|
@ -1,16 +1,16 @@
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
changelog:
|
||||||
|
name: Changelog
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -24,3 +24,37 @@ jobs:
|
||||||
- run: npx changelogithub
|
- run: npx changelogithub
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||||
|
|
||||||
|
publish-npm:
|
||||||
|
name: Publish to npm
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Ensure npm supports trusted publishing
|
||||||
|
run: npm install -g npm@latest
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm build
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
|
run: |
|
||||||
|
VERSION="$(node -p "require('./package.json').version")"
|
||||||
|
if [[ "$VERSION" == *-* ]]; then
|
||||||
|
TAG="${VERSION#*-}"; TAG="${TAG%%.*}"
|
||||||
|
else
|
||||||
|
TAG="latest"
|
||||||
|
fi
|
||||||
|
echo "Publishing $VERSION to dist-tag '$TAG'"
|
||||||
|
npm publish --access public --tag "$TAG"
|
||||||
|
|
|
||||||
46
.github/workflows/test.yml
vendored
Normal file
46
.github/workflows/test.yml
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
# Cancel superseded runs on the same ref (e.g. new pushes to a PR).
|
||||||
|
concurrency:
|
||||||
|
group: tests-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
unit:
|
||||||
|
name: Unit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm test
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
name: E2E
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
# Chromium only on push/PR — the full Firefox/WebKit matrix runs nightly
|
||||||
|
# (.github/workflows/nightly.yml).
|
||||||
|
- name: Install Playwright Chromium
|
||||||
|
run: pnpm exec playwright install --with-deps chromium
|
||||||
|
- run: pnpm test:e2e
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
|
|
@ -15,4 +15,16 @@ dist/
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
# vitest
|
# vitest
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
|
# storybook (all generated: `pnpm -F storybook analyze` / `build`)
|
||||||
|
storybook/storybook-static
|
||||||
|
storybook/custom-elements.json
|
||||||
|
|
||||||
|
# VS Code HTML custom data, emitted to the repo root by the same analyze run
|
||||||
|
# so `html.customData` in .vscode/settings.json can resolve it
|
||||||
|
vscode.html-custom-data.json
|
||||||
|
vscode.css-custom-data.json
|
||||||
|
|
||||||
|
# `cem analyze` outputs using `wcb` plugins
|
||||||
|
.wcb
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
npm run lint --cache
|
# Everything runs at commit time. This repo auto-pushes to multiple remotes
|
||||||
npm run test
|
# (github, forgejo, sourcehut — see post-commit), so a pre-push hook would
|
||||||
npm run build
|
# re-run these once per remote; keeping them here runs them once per commit.
|
||||||
npx size-limit
|
# The full cross-browser e2e matrix still runs nightly in CI. Order: cheap → expensive.
|
||||||
|
pnpm exec lint-staged
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
examples/
|
demo/
|
||||||
assets/
|
assets/
|
||||||
src/
|
src/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
{
|
{
|
||||||
"js/ts.implicitProjectConfig.checkJs": true,
|
"js/ts.implicitProjectConfig.checkJs": true,
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"html.customData": ["./vscode.html-custom-data.json"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
AGENTS.md
53
AGENTS.md
|
|
@ -14,10 +14,18 @@ This file provides guidance to AI coding agents when working with code in this r
|
||||||
- `pnpm lint` — ESLint (flat config in `eslint.config.mjs`, includes `eslint-plugin-jsdoc`).
|
- `pnpm lint` — ESLint (flat config in `eslint.config.mjs`, includes `eslint-plugin-jsdoc`).
|
||||||
- `pnpm format` — Prettier over the repo.
|
- `pnpm format` — Prettier over the repo.
|
||||||
- `pnpm build` — `tsc` emits `.d.ts` types, then `copy:source` uses esbuild to minify/bundle `src/*.js` + `src/utils/*` into `dist/` as ESM. **`dist/` is what gets published; `src/` is shipped as the readable source.**
|
- `pnpm build` — `tsc` emits `.d.ts` types, then `copy:source` uses esbuild to minify/bundle `src/*.js` + `src/utils/*` into `dist/` as ESM. **`dist/` is what gets published; `src/` is shipped as the readable source.**
|
||||||
- `pnpm size-limit` — enforces the byte budgets declared in `package.json` `size-limit` (e.g. `WebComponent.js` ≤ 1.2 KB). Keep additions tiny; this gate is a core project value.
|
- `pnpm size-limit` — enforces the per-file byte budgets declared in `package.json` `size-limit` (each `dist/` file has its own, e.g. `WebComponent.js` ≤ 2.05 KB, `html.js` ≤ 0.6 KB, each util ≤ 0.5–0.8 KB). Keep additions tiny; this gate is a core project value. `size-change-log.md` is the running record of how each change moved the base-class bundle and why; its latest row's min+brotli figure is the single source of truth for the base-class size, so `docs/src/content/docs/guides/library-size.md` must be updated in lockstep whenever a new row lands.
|
||||||
|
- `pnpm test:types` — builds, then type-checks the TS type tests (`test/types/typed-props.test-d.ts`) and the `demo/examples/typed-props/` example. `this.props` is typed by passing the props shape as a class type argument: `class X extends WebComponent<typeof props>`.
|
||||||
|
- `pnpm test:template` — scaffolds `create/template/` into a temp directory with the real scaffolder, links this repo in as `web-component-base`, and type-checks it with the template's own `tsconfig.json` (`scripts/check-template.js`). It resolves through the root `exports` map, so it catches a template that can't resolve the package — the failure mode that leaves every `this.props` access an error while `vite build` still exits 0.
|
||||||
- `pnpm docs` — run the Astro docs site (`docs/` workspace) locally.
|
- `pnpm docs` — run the Astro docs site (`docs/` workspace) locally.
|
||||||
|
- `pnpm demo` — run the Vite examples showcase (`demo/` workspace) locally; `pnpm demo:build` builds it.
|
||||||
|
- `pnpm -F storybook dev` — run the Storybook testing ground (`storybook/`) locally; `pnpm -F storybook build` builds it. Both run `cem analyze` first to regenerate `custom-elements.json`. The CEM config imports the plugin from the **published subpath** (`web-component-base/cem-plugin` → `dist/`), so run `pnpm build` at the root first.
|
||||||
|
- `pnpm test:e2e` — run the browser e2e specs (`test/e2e/`) via Vitest browser mode (Playwright). Defaults to Chromium; `pnpm test:e2e:firefox` / `:webkit` / `:all` target the other engines (the `E2E_BROWSERS` env var, comma-separated, selects instances). `pnpm test:all` runs unit + types + template + e2e across all engines.
|
||||||
|
- `pnpm create wcb` (the `create/` workspace package) scaffolds a new component project from `create/template/`. `create/` is a separate publishable package, not part of the `web-component-base` bundle.
|
||||||
|
|
||||||
pnpm is mandatory (a `preinstall` `only-allow pnpm` guard enforces it). This is a pnpm workspace; the only sub-package is `docs/`.
|
pnpm is mandatory (a `preinstall` `only-allow pnpm` guard enforces it). This is a pnpm workspace with three sub-packages: `docs/` (Astro docs site), `demo/` (Vite examples showcase, which consumes the root `web-component-base` package as a `workspace:*` dependency), and `storybook/` (Storybook testing ground — it writes no components of its own, it builds stories over the `demo/examples/` components so the CEM plugin is exercised against real ones). The runnable example sources live in `demo/examples/` and import the package by name (`web-component-base`), not via relative `src/` paths.
|
||||||
|
|
||||||
|
The demo shares one app shell: `demo/shell.css` (design tokens + component styles, linked by the landing page and every example) and `demo/shell.js` (defines `<app-header>` — itself a `WebComponent` — and `prepend`s it to each example page; `prepend` never reparents the example's own elements, so their lifecycle is untouched). Every example page links both. Keep new examples consistent by adding `<link rel="stylesheet" href="../../shell.css" />` and `<script type="module" src="../../shell.js"></script>`.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -25,15 +33,31 @@ Everything is in `src/` (entry point `src/index.js` re-exports `WebComponent` an
|
||||||
|
|
||||||
- **`src/WebComponent.js`** — the base class and the whole reactivity engine. The flow:
|
- **`src/WebComponent.js`** — the base class and the whole reactivity engine. The flow:
|
||||||
- `static props` (a camelCase → default-value map) is the single source of truth. `observedAttributes` is derived from it by kebab-casing each key.
|
- `static props` (a camelCase → default-value map) is the single source of truth. `observedAttributes` is derived from it by kebab-casing each key.
|
||||||
- The constructor deep-clones `static props`, records each prop's `typeof` in a private `#typeMap`, reflects defaults to attributes, and wraps the props object in a **Proxy** (`#handler`). Writing `this.props.someProp = x` serializes the value and calls `setAttribute` (kebab-case), which triggers `attributeChangedCallback` → `render()`. That attribute-write-driven cycle *is* the reactivity model — there is no separate scheduler.
|
- The constructor only clones defaults and sets up the host — it does **not** touch attributes (the spec forbids attribute mutation in the constructor). `cloneDefaults` deep-copies each default with `structuredClone`, falling back to by-reference for non-cloneable values (functions, class instances) instead of throwing `DataCloneError`, and warns once per class about defaults that can't reflect to an attribute (function/symbol) or a discouraged `true` boolean default. Each prop's `typeof` is recorded in a private `#typeMap`.
|
||||||
- The Proxy enforces type stability against `#typeMap` and throws `TypeError` on a type change.
|
- Defaults are reflected onto attributes in `connectedCallback` (`#reflectDefaults`), skipping any attribute already set by markup/SSR so authored values win. The props object is wrapped in a **Proxy** (`#handler`): writing `this.props.someProp = x` runs `toAttribute` and calls `setAttribute`/`removeAttribute` (kebab-case), which triggers `attributeChangedCallback` → `render()`. That attribute-write-driven cycle *is* the reactivity model — there is no separate scheduler. `#reflecting` tracks the attribute currently being written (per name, not a shared flag) so the reflection's own `attributeChangedCallback` doesn't parse the value back.
|
||||||
- Lifecycle hooks authors may override: `onInit()` (connected), `afterViewInit()` (after first render), `onDestroy()` (disconnected), `onChanges({property, previousValue, currentValue})` (attribute changed). Note `onChanges` currently receives the **kebab-case** `property`.
|
- The Proxy guards prop types against `#typeMap`. A type violation is **logged via `console.error` and skipped** by default, so a stray write can't halt `render()`/`onChanges`; set `static strictProps = true` to throw `TypeError` instead. Undeclared props are untyped.
|
||||||
- `template` is a read-only getter (assigning to it throws). `render()` branches on its type: a **string** template is assigned to `innerHTML` directly; an **object** template (a vnode tree from `html`) is diffed against the previous tree with `JSON.stringify` and, on change, rebuilt via `createElement` + `replaceChildren`. `render()` can be called manually or overridden entirely (e.g. to delegate to `lit-html`).
|
- `attributeChangedCallback` handles a *removed* attribute (`currentValue === null`) by resetting to the declared default — or `false` for a boolean prop. A removed or empty-string attribute still runs `render()`/`onChanges` (`''` stays `''`). Attribute-driven side effects are buffered until after `connectedCallback` runs `onInit`, so `onInit` always precedes the first render even when the platform fires attribute callbacks before connect.
|
||||||
- `static styles` are applied as a constructable `CSSStyleSheet` via `adoptedStyleSheets`, which **only works with a shadow root** — set `static shadowRootInit` to opt into shadow DOM.
|
- **Boolean props follow HTML**: presence means `true`, absence means `false`. `true` reflects as a bare attribute, `false` removes it, so `toggleAttribute()` and `[attr]` selectors work; any present value (even `flag="false"`) is `true`, and wcb warns when it sees a boolean attribute written as `"true"`/`"false"`. `toAttribute(name, value)` / `fromAttribute(name, value)` are overridable per-prop converters (call `super` for the props you don't handle); `toAttribute` returning `null` removes the attribute.
|
||||||
|
- Lifecycle hooks authors may override: `onInit()` (connected), `afterViewInit()` (after first render), `onDestroy()` (disconnected), `onChanges({property, attribute, previousValue, currentValue})` (attribute changed) — `property` is **camelCase** (matching `props` access) and `attribute` is the kebab-case name.
|
||||||
|
- `template` is a read-only getter (assigning to it throws). `render()` branches on its type: a **string** template is written to `#host.innerHTML`; an **object** template (a vnode tree from `html`) is diffed against the previous tree with `JSON.stringify` and, on change, built via `createElement` + `replaceChildren` on the *first* render and **reconciled in place** (`patchChildren`, see `src/utils/patch.mjs`) on every render after that, so focus/caret/uncommitted input values survive. An empty string (and `` html`` `` , which yields `undefined`) empties the rendered subtree. `render()` can be called manually or overridden entirely (e.g. to delegate to `lit-html`).
|
||||||
|
- `static styles` (a string, `CSSStyleSheet`, or an array of them adopted in declaration order — so a shared tokens sheet can precede component styles) are applied as constructable stylesheets via `adoptedStyleSheets`, which **only works with a shadow root** — set `static shadowRootInit` to opt into shadow DOM.
|
||||||
|
|
||||||
- **`src/html.js`** — the `html` tagged-template function. It is a bundled/minified copy of [`htm`](https://github.com/developit/htm) bound to a hyperscript `h(type, props, children)` that produces plain `{type, props, children}` vnodes. License lives in `vendors/htm/`.
|
- **`src/html.js`** — the `html` tagged-template function. It imports [`htm`](https://github.com/developit/htm) (the `htm/mini` build, a `devDependency`) and binds it to a hyperscript `h(type, props, children)` that produces plain `{type, props, children}` vnodes. `htm` is bundled into `dist/` at build time by esbuild's `--bundle`, so the published package stays zero-runtime-dependency. License lives in `vendors/htm/`.
|
||||||
|
|
||||||
- **`src/utils/`** — the serialization layer that bridges typed JS values and string attributes: `serialize`/`deserialize` (JSON round-trip for number/boolean/object, passthrough for strings), `get-camel-case`/`get-kebab-case` (attribute ⇄ prop name conversion), and `create-element` (turns a vnode tree into real DOM nodes, resolving props to DOM properties/attributes and applying `style` objects). `src/utils/index.js` re-exports all of them.
|
- **`src/cem-plugin.js`** — a **dev-time-only** Custom Elements Manifest analyzer plugin that teaches `@custom-elements-manifest/analyzer` about wcb's `static props` convention (mapping props to CEM attributes/members and hiding wcb internals). It runs in Node during `cem analyze`, is never imported by `WebComponent`, and is published as the `web-component-base/cem-plugin` subpath (`dist/`) — which is why Storybook/CEM consumers must `pnpm build` the root first.
|
||||||
|
|
||||||
|
- **`src/utils/`** — the serialization layer that bridges typed JS values and string attributes: `serialize`/`deserialize` (JSON round-trip for number/boolean/object, passthrough for strings), `get-camel-case`/`get-kebab-case` (attribute ⇄ prop name conversion), `create-element` (turns a vnode tree into real DOM nodes, resolving props to DOM properties/attributes and applying `style` objects — its `applyProp` is the single source of truth for the prop→DOM rule), and `patch` (index-based, non-keyed reconciler used by re-renders; it reuses same-tag elements, applies prop adds/changes/**removals** via `applyProp`, and trims trailing nodes). `src/utils/index.js` re-exports all of them.
|
||||||
|
|
||||||
|
## Definition of done for a behavior change
|
||||||
|
|
||||||
|
Any change to observable behavior ships as one unit — code alone is an incomplete change. Land all four together:
|
||||||
|
|
||||||
|
1. **Tests** — unit specs in `test/` (or colocated `*.test.mjs`) covering the new contract *and* the old behavior it replaces. Add a `test/e2e/` spec whenever the behavior depends on something happy-dom cannot model faithfully — CSS selector matching, computed styles, custom-element upgrade timing.
|
||||||
|
2. **Demo examples** — a runnable example under `demo/examples/` that demonstrates the behavior, linked from a card in `demo/index.html`. Update any existing example the change affects, including ones that now emit a console warning or model a discouraged pattern.
|
||||||
|
3. **Documentation** — the guide under `docs/src/content/docs/guides/` that doubles as the behavioral spec. For a breaking change, also update the `README.md` banner with the migration consumers have to perform.
|
||||||
|
4. **Size budget** — `pnpm size-limit` stays green. If an addition genuinely needs more headroom, raise the budget in `package.json` deliberately and say so in the change description; never let it drift silently. Whenever the change moves the base-class bundle, record it as a new row in `size-change-log.md` **and** update the headline size in `docs/src/content/docs/guides/library-size.md` to match that row's new min+brotli figure — the two must never disagree.
|
||||||
|
|
||||||
|
Verify with `pnpm test:all` (unit + types + template + e2e across all engines) before calling the change done.
|
||||||
|
|
||||||
## Testing notes
|
## Testing notes
|
||||||
|
|
||||||
|
|
@ -41,9 +65,16 @@ Everything is in `src/` (entry point `src/index.js` re-exports `WebComponent` an
|
||||||
- Tests live both in `test/` and colocated next to source as `*.test.mjs` (e.g. `src/utils/serialize.test.mjs`). Coverage is restricted to `src`.
|
- Tests live both in `test/` and colocated next to source as `*.test.mjs` (e.g. `src/utils/serialize.test.mjs`). Coverage is restricted to `src`.
|
||||||
- happy-dom does not perfectly match browser semantics (notably custom-element upgrade ordering); be skeptical of behaviors that depend on real-browser timing.
|
- happy-dom does not perfectly match browser semantics (notably custom-element upgrade ordering); be skeptical of behaviors that depend on real-browser timing.
|
||||||
|
|
||||||
## Known correctness work in flight
|
## State-model invariants (easy to regress)
|
||||||
|
|
||||||
The README's top-of-file note flags a set of state-corruption fixes as the current priority — constructor attribute writes, empty-string/removed-attribute handling silently skipping `render()`/`onChanges`, the props Proxy's first-write type lock, and unsafe `structuredClone` of `static props` defaults. Keep these in mind when touching `WebComponent.js`; several current behaviors described above are the *target of* upcoming changes.
|
The v6 state-correctness work has **landed** — the behaviors described above are the *current* contract, not aspirational targets. When touching `WebComponent.js`, preserve these invariants:
|
||||||
|
|
||||||
|
- The constructor never mutates attributes; default reflection happens in `connectedCallback` (`#reflectDefaults`), and authored attributes are never overwritten.
|
||||||
|
- A removed or empty-string attribute still runs `render()`/`onChanges` — removal resets to the declared default (or `false` for booleans), `''` stays `''`.
|
||||||
|
- Prop-type violations **log-and-skip** unless `static strictProps = true`; a bad write never halts rendering.
|
||||||
|
- Defaults are cloned with a `structuredClone` fallback, so non-cloneable defaults (functions, class instances) don't throw.
|
||||||
|
- `onInit` always precedes the first render (attribute-driven side effects are buffered until connect).
|
||||||
|
- Boolean props follow HTML presence/absence semantics in both directions.
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
|
|
|
||||||
2
CLAUDE.md
Normal file
2
CLAUDE.md
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
@AGENTS.md
|
||||||
|
|
||||||
40
README.md
40
README.md
|
|
@ -1,26 +1,41 @@
|
||||||
# Web Component Base
|
# Web Component Base
|
||||||
|
|
||||||
> [!Note]
|
> [!Note]
|
||||||
> **What's next: correctness fixes.** Thanks to feedback from teams building on WCB, our immediate focus is a set of state-corruption fixes: no attribute writes in the constructor, correct handling of empty-string and removed attributes (so `render()`/`onChanges` are never silently skipped), removing the props proxy's first-write type lock, and safe cloning of `static props` defaults. Detailed issues will follow.
|
> **HTML boolean attributes semantics shipped in v6** - A boolean
|
||||||
|
> prop follows the HTML convention in both directions: **presence means `true`,
|
||||||
|
> absence means `false`**. `true` reflects as a bare attribute and `false`
|
||||||
|
> removes it, so `toggleAttribute()` and `[attr]` CSS selectors finally work as
|
||||||
|
> expected.
|
||||||
|
>
|
||||||
|
> **Any present value is `true`** — including the literal `flag="false"`, just
|
||||||
|
> like native `disabled="false"` is still disabled. If you write boolean
|
||||||
|
> attributes as `setAttribute(name, String(bool))`, that now always means
|
||||||
|
> `true`; switch those call sites to `toggleAttribute(name, bool)`. wcb warns
|
||||||
|
> in the console when it sees a boolean attribute written as `"true"`/`"false"`
|
||||||
|
> so the change cannot fail silently. Attributes whose `"false"` is meaningful
|
||||||
|
> (`aria-*`, `contenteditable`) should be declared as **string** props.
|
||||||
|
>
|
||||||
|
> See [Prop Access](https://webcomponent.io/prop-access/#boolean-props) for details.
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/web-component-base)
|
[](https://npmx.dev/package/web-component-base)
|
||||||
[](https://www.npmjs.com/package/web-component-base)
|
[](https://npmx.dev/package/web-component-base)
|
||||||
[](https://www.npmjs.com/package/web-component-base)
|
[](https://npmx.dev/package/web-component-base)
|
||||||
[](#library-size)
|
[](#library-size)
|
||||||
|
|
||||||
🤷♂️ zero-dependency, 🤏 tiny JS base class for creating reactive [custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_Components/Using_custom_elements) easily ✨
|
🤷♂️ zero-dependency, 🤏 tiny JS base class for creating reactive [custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_Components/Using_custom_elements) easily ✨
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
When you extend the `WebComponent` class for your component, you only have to define the `template` and `properties`. Any change in any property value will automatically cause just the component UI to render.
|
When you extend the `WebComponent` class for your component, you only have to define the `template` and `properties`. Any change in any property value will automatically cause just the component UI to render.
|
||||||
|
|
||||||
The result is a reactive UI on property changes.
|
The result is a reactive UI on property changes.
|
||||||
|
|
||||||
## Links
|
Next actions:
|
||||||
|
|
||||||
- [Documentation](https://webcomponent.io)
|
1. [Read the docs](https://webcomponent.io)
|
||||||
- [Read a blog explaining the reactivity](https://ayos.blog/reactive-custom-elements-with-html-dataset/)
|
2. [View demos with code examples](https://demo.webcomponent.io/)
|
||||||
- [View demo on CodePen](https://codepen.io/ayoayco-the-styleful/pen/ZEwoNOz?editors=1010)
|
3. [Play with a demo on CodePen](https://codepen.io/ayo-run/pen/ZEwoNOz?editors=1010).
|
||||||
|
4. [Try it on TypeScript playground](https://www.typescriptlang.org/play/?#code/JYWwDg9gTgLgBAbzgdQKYCMDCFwQHap4wA0cAFjCADZwC+cAZlDnAOQDuGAtAMY6QEiXdAEMAzqlYAoKQHoAVPKlx5cACKoeVEVFRwRcGAE8wehtDhGIAVyhwABmJgiYwHnDDMwY+8tUBJeGAxODwIeF0AR2tgXQATRgt2HnRDCDh2aABrUnRrCJcyVDsRPD84CDAYLmA8OD5wYCpULlcQPWNTODERBlRjDOAYMjgAFRNUMR4oYCrygAEJPQoYbwAuWVlOdAaBQhgAOmAIWU9KrhEeHkmxWQBiSura1om4rjPvGrwX0ymZuZUsiknT0yBSACF8jB8AAFLwhAC8iGUcG06FQVDW3RgMzwAHMUXFgiJ0M04lj0BAIM1SiitG4smIsXhrCB0VApLQZAolCo4ABBbE6GDFer8fD7A5wACiADdikY4FlUIrag4nC43B54fY4MF9HBdJdXPLtZVisZyroGM0eCKEtCDcrROheOJUAkXDjgHkRXBACgEGRmrnxDmGwQOHzEB3pPEZAGp474+dYwHEXJNDEV9DBvb69KUErouLo8HFitG-EDUAAPSCweraMQhMHoSG5-BwWsisstjDYXCCGAAHlb7eheDhlTEAD5kXAha53FGsWOobD4XAkQgUQu0RisaxMFQGXB2qxiLu4ESeqSPViGCIqBJLwuF7HGViAAyvugyBd5OAW5wAAFAAlFuc47m+eoMKBACE4bRlGBw3iSZIQUhkbwjGJ5xmIiYolyKJ4v0hioOA2giuB85vroMC2HUFDUMmMFwMOvoThUeAfgiAAkCBYYBYD0Ghd5xPxglkBGKFiWStAzleMECVhKH7lQ9CBip0nIThH5iLQADrSnsbInH4IpMGsVyxE8NYTg4NKzTtEQ0blgwtSoCBHApMI654BeKAQv5YFSEAA)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Want to get in touch?
|
## Want to get in touch?
|
||||||
|
|
||||||
|
|
@ -32,6 +47,7 @@ There are many ways to get in touch:
|
||||||
|
|
||||||
## Inspirations and thanks
|
## Inspirations and thanks
|
||||||
|
|
||||||
|
1. [Sara Rainsberger](https://rainsberger.ca/) - I never stop singing praises to the Astro documentation team and I use Sarah's ["50 docs tips in 50 days"](https://www.rainsberger.ca/docs-tips/) as a guide in reviewing the [documentation for wcb](https://webcomponent.io). I do not have a team myself, and do not have the level of excellence Sarah brings to reviewing docs. I often need the assistance and have encodified a [docs-review skill](https://github.com/ayo-run/wcb/blob/main/.claude/skills/docs-review/SKILL.md) for it *with permission*.
|
||||||
1. [htm](https://github.com/developit/htm) - I use it for the `html` function for tagged templates, and take a lot of inspiration in building the rendering implementation. It is highly likely that I will go for what Preact is doing... but we'll see.
|
1. [htm](https://github.com/developit/htm) - I use it for the `html` function for tagged templates, and take a lot of inspiration in building the rendering implementation. It is highly likely that I will go for what Preact is doing... but we'll see.
|
||||||
1. [fast](https://github.com/microsoft/fast) - When I found that Microsoft has their own base class I thought it was super cool!
|
1. [fast](https://github.com/microsoft/fast) - When I found that Microsoft has their own base class I thought it was super cool!
|
||||||
1. [lit](https://github.com/lit/lit) - `lit-html` continues to amaze me and I worked to make `wcb` generic so I (and others) can continue to use it
|
1. [lit](https://github.com/lit/lit) - `lit-html` continues to amaze me and I worked to make `wcb` generic so I (and others) can continue to use it
|
||||||
|
|
@ -40,6 +56,10 @@ There are many ways to get in touch:
|
||||||
|
|
||||||
See [`size-change-log.md`](./size-change-log.md) for a running record of how each correctness/feature change affects the `WebComponent` base class bundle size, with the reason and benefit of each.
|
See [`size-change-log.md`](./size-change-log.md) for a running record of how each correctness/feature change affects the `WebComponent` base class bundle size, with the reason and benefit of each.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
This is the base class used for web components in Ayo's projects, primarily [cozy-games](https://git.ayo.run/ayo/cozy-games), [mcfly](https://git.ayo.run/ayo/mcfly/), his [personal site](https://ayo.ayco.io), his [blog](https://ayos.blog), and [others](https://git.ayo.run/ayo).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_Just keep building._<br>
|
_Just keep building._<br>
|
||||||
|
|
|
||||||
59
create/README.md
Normal file
59
create/README.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# create-wcb
|
||||||
|
|
||||||
|
> [!Note] Work in progress CLI for wcb development.
|
||||||
|
> Currently, it scaffolds a new web component project
|
||||||
|
> with our CEM Analyzer Plugin already configured
|
||||||
|
|
||||||
|
Scaffold a publishable [web-component-base](https://webcomponent.io) (wcb)
|
||||||
|
custom element:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm create wcb@latest
|
||||||
|
# or: npm create wcb@latest my-button
|
||||||
|
# or: pnpm create wcb / yarn create wcb / bun create wcb
|
||||||
|
```
|
||||||
|
|
||||||
|
## What you get
|
||||||
|
|
||||||
|
- A custom element built on wcb's `static props` convention, written in
|
||||||
|
TypeScript — the class, tag, and file names are stamped from your project
|
||||||
|
name (`my-button` → `MyButton` / `<my-button>`), so there are no rename-me
|
||||||
|
TODOs
|
||||||
|
- A Vite setup patterned on the
|
||||||
|
[`ayo-run/web-component`](https://github.com/ayo-run/web-component)
|
||||||
|
template: `npm run dev` serves the demo page, `npm run build:lib` produces
|
||||||
|
ESM + UMD bundles and `.d.ts` types in `dist/`, with `web-component-base`
|
||||||
|
external as a peerDependency
|
||||||
|
- `custom-elements.json` generation set up end to end:
|
||||||
|
- `custom-elements-manifest.config.mjs` configured with
|
||||||
|
[`web-component-base/cem-plugin`](https://webcomponent.io/cem-plugin/), so
|
||||||
|
every `static props` key becomes a typed attribute + field in the manifest
|
||||||
|
- an `analyze` script (`npm run analyze`) running
|
||||||
|
`@custom-elements-manifest/analyzer`
|
||||||
|
- the `customElements` field set in `package.json`, which is how Storybook,
|
||||||
|
editors, and other tooling discover the manifest
|
||||||
|
- the manifest ships inside the published package: `prepack` rebuilds
|
||||||
|
`dist/` and regenerates `custom-elements.json`, and both are in `files`
|
||||||
|
|
||||||
|
The manifest is one shared `custom-elements.json` per package — the file every
|
||||||
|
tool expects — regenerated on demand and gitignored in the repo.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm create wcb@latest [directory]
|
||||||
|
```
|
||||||
|
|
||||||
|
Prompts for a directory when omitted; `.` scaffolds into the current (empty)
|
||||||
|
directory. Refuses to write into a non-empty directory. A hyphen-less name
|
||||||
|
gets an `-element` suffix on the tag (`button` → `<button-element>`), since
|
||||||
|
custom element tag names require a hyphen.
|
||||||
|
|
||||||
|
Prefer starting from GitHub instead of the terminal? The
|
||||||
|
[`ayo-run/web-component`](https://github.com/ayo-run/web-component) template
|
||||||
|
repository serves the same purpose via "Use this template".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Part of the [wcb](https://github.com/ayo-run/wcb) project · MIT ·
|
||||||
|
[Ayo Ayco](https://ayo.ayco.io)
|
||||||
142
create/create.test.mjs
Normal file
142
create/create.test.mjs
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
// @vitest-environment node
|
||||||
|
// (the default happy-dom environment replaces the global URL with its DOM
|
||||||
|
// shim, which `fileURLToPath` rejects; this suite needs no DOM anyway)
|
||||||
|
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const bin = fileURLToPath(new URL('./index.js', import.meta.url))
|
||||||
|
|
||||||
|
describe('create-wcb', () => {
|
||||||
|
let workDir
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'create-wcb-'))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(workDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const run = (args, options = {}) =>
|
||||||
|
execFileSync(process.execPath, [bin, ...args], {
|
||||||
|
cwd: workDir,
|
||||||
|
encoding: 'utf8',
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
|
||||||
|
const readJson = (...segments) =>
|
||||||
|
JSON.parse(fs.readFileSync(path.join(workDir, ...segments), 'utf8'))
|
||||||
|
|
||||||
|
const read = (...segments) =>
|
||||||
|
fs.readFileSync(path.join(workDir, ...segments), 'utf8')
|
||||||
|
|
||||||
|
it('scaffolds a project with the CEM story set up', () => {
|
||||||
|
run(['my-button'])
|
||||||
|
|
||||||
|
const root = path.join(workDir, 'my-button')
|
||||||
|
for (const file of [
|
||||||
|
'index.html',
|
||||||
|
'src/my-button.ts',
|
||||||
|
'custom-elements-manifest.config.mjs',
|
||||||
|
'vite-lib.config.ts',
|
||||||
|
'tsconfig.json',
|
||||||
|
'pnpm-workspace.yaml',
|
||||||
|
'README.md',
|
||||||
|
])
|
||||||
|
expect(fs.existsSync(path.join(root, file)), file).toBe(true)
|
||||||
|
|
||||||
|
const packageJson = readJson('my-button', 'package.json')
|
||||||
|
expect(packageJson.name).toBe('my-button')
|
||||||
|
expect(packageJson.customElements).toBe('custom-elements.json')
|
||||||
|
expect(packageJson.scripts.analyze).toBe('cem analyze')
|
||||||
|
expect(packageJson.scripts['build:lib']).toContain('vite-lib.config.ts')
|
||||||
|
expect(packageJson.files).toContain('custom-elements.json')
|
||||||
|
expect(packageJson.peerDependencies['web-component-base']).toBeDefined()
|
||||||
|
expect(
|
||||||
|
packageJson.devDependencies['@custom-elements-manifest/analyzer']
|
||||||
|
).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stamps the component identity from the project name', () => {
|
||||||
|
const output = run(['my-button'])
|
||||||
|
expect(output).toContain('<my-button>')
|
||||||
|
|
||||||
|
const component = read('my-button', 'src', 'my-button.ts')
|
||||||
|
expect(component).toContain('export class MyButton')
|
||||||
|
expect(component).toContain("customElements.define('my-button', MyButton)")
|
||||||
|
|
||||||
|
expect(read('my-button', 'index.html')).toContain('<my-button')
|
||||||
|
expect(read('my-button', 'vite-lib.config.ts')).toContain(
|
||||||
|
'src/my-button.ts'
|
||||||
|
)
|
||||||
|
|
||||||
|
const packageJson = readJson('my-button', 'package.json')
|
||||||
|
expect(packageJson.module).toBe('./dist/my-button.js')
|
||||||
|
expect(packageJson.types).toBe('./dist/my-button.d.ts')
|
||||||
|
|
||||||
|
// no template identity survives the stamp
|
||||||
|
for (const file of [
|
||||||
|
'package.json',
|
||||||
|
'index.html',
|
||||||
|
'README.md',
|
||||||
|
'vite-lib.config.ts',
|
||||||
|
path.join('src', 'my-button.ts'),
|
||||||
|
]) {
|
||||||
|
expect(read('my-button', file)).not.toContain('wcb-button')
|
||||||
|
expect(read('my-button', file)).not.toContain('WcbButton')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suffixes hyphen-less names so the tag is valid', () => {
|
||||||
|
run(['button'])
|
||||||
|
|
||||||
|
expect(readJson('button', 'package.json').name).toBe('button')
|
||||||
|
const component = read('button', 'src', 'button-element.ts')
|
||||||
|
expect(component).toContain(
|
||||||
|
"customElements.define('button-element', ButtonElement)"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renames _gitignore to .gitignore', () => {
|
||||||
|
run(['my-button'])
|
||||||
|
|
||||||
|
const root = path.join(workDir, 'my-button')
|
||||||
|
expect(fs.existsSync(path.join(root, '.gitignore'))).toBe(true)
|
||||||
|
expect(fs.existsSync(path.join(root, '_gitignore'))).toBe(false)
|
||||||
|
expect(read('my-button', '.gitignore')).toContain('custom-elements.json')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizes the directory name into a valid package name', () => {
|
||||||
|
run(['My Button'])
|
||||||
|
expect(readJson('My Button', 'package.json').name).toBe('my-button')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scaffolds into the current directory with "."', () => {
|
||||||
|
run(['.'])
|
||||||
|
expect(fs.existsSync(path.join(workDir, 'index.html'))).toBe(true)
|
||||||
|
// package name derives from the temp directory's basename
|
||||||
|
expect(readJson('package.json').name).toMatch(/^create-wcb-/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the default directory when the prompt is empty', () => {
|
||||||
|
run([], { input: '\n' })
|
||||||
|
expect(fs.existsSync(path.join(workDir, 'wcb-button', 'index.html'))).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(readJson('wcb-button', 'package.json').name).toBe('wcb-button')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a non-empty target directory', () => {
|
||||||
|
fs.mkdirSync(path.join(workDir, 'taken'))
|
||||||
|
fs.writeFileSync(path.join(workDir, 'taken', 'keep.txt'), 'important')
|
||||||
|
|
||||||
|
expect(() => run(['taken'], { stdio: 'pipe' })).toThrow(/not empty/)
|
||||||
|
expect(read('taken', 'keep.txt')).toBe('important')
|
||||||
|
})
|
||||||
|
})
|
||||||
195
create/index.js
Normal file
195
create/index.js
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* @license MIT <https://opensource.org/licenses/MIT>
|
||||||
|
* @author Ayo Ayco <https://ayo.ayco.io>
|
||||||
|
*
|
||||||
|
* Scaffolds a wcb (`web-component-base`) component: a publishable custom
|
||||||
|
* element with a Vite library build (ESM + UMD + types), a demo page, and
|
||||||
|
* `custom-elements.json` generation already set up — the CEM analyzer config
|
||||||
|
* with wcb's plugin, an `analyze` script, and the `customElements` field in
|
||||||
|
* `package.json`. The component class, tag, and file names are stamped from
|
||||||
|
* the project name, so no rename-me TODOs are left behind.
|
||||||
|
*
|
||||||
|
* Runs via `npm create wcb@latest [directory]` (npm resolves `create wcb` to
|
||||||
|
* this package's bin). Zero dependencies: prompts use `node:readline`.
|
||||||
|
* @see https://webcomponent.io/cem-plugin/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
import readline from 'node:readline/promises'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const DEFAULT_DIR = 'wcb-button'
|
||||||
|
/** The component identity used inside the template, rewritten on scaffold. */
|
||||||
|
const TEMPLATE_TAG = 'wcb-button'
|
||||||
|
const TEMPLATE_CLASS = 'WcbButton'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes a directory name into a valid npm package name.
|
||||||
|
* @param {string} name the raw project directory name
|
||||||
|
* @returns {string} a lowercase, hyphenated package name
|
||||||
|
*/
|
||||||
|
function toValidPackageName(name) {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/^[._]+/, '')
|
||||||
|
.replace(/[^a-z0-9-~._]+/g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a custom element tag name from the package name. Tag names must
|
||||||
|
* start with a lowercase letter and contain a hyphen, so a hyphen-less name
|
||||||
|
* gets an `-element` suffix (`button` → `button-element`).
|
||||||
|
* @param {string} packageName the sanitized package name
|
||||||
|
* @returns {string} a valid custom element tag name
|
||||||
|
*/
|
||||||
|
function toTagName(packageName) {
|
||||||
|
let tag = packageName
|
||||||
|
.replace(/[^a-z0-9-]+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
if (!tag) return TEMPLATE_TAG
|
||||||
|
if (!/^[a-z]/.test(tag)) tag = `wc-${tag}`
|
||||||
|
if (!tag.includes('-')) tag = `${tag}-element`
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a tag name to its PascalCase class name (`my-button` → `MyButton`).
|
||||||
|
* @param {string} tag the custom element tag name
|
||||||
|
* @returns {string} the PascalCase class name
|
||||||
|
*/
|
||||||
|
function toClassName(tag) {
|
||||||
|
return tag
|
||||||
|
.split('-')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part[0].toUpperCase() + part.slice(1))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists every file under a directory recursively.
|
||||||
|
* @param {string} dir the directory to walk
|
||||||
|
* @returns {string[]} absolute file paths
|
||||||
|
*/
|
||||||
|
function walkFiles(dir) {
|
||||||
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const full = path.join(dir, entry.name)
|
||||||
|
return entry.isDirectory() ? walkFiles(full) : [full]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects the package manager that launched this scaffold, so the printed
|
||||||
|
* next steps match how the user invoked it (`npm create` / `pnpm create` /
|
||||||
|
* `yarn create` / `bun create`).
|
||||||
|
* @returns {string} `npm` | `pnpm` | `yarn` | `bun`
|
||||||
|
*/
|
||||||
|
function detectPackageManager() {
|
||||||
|
const userAgent = process.env.npm_config_user_agent ?? ''
|
||||||
|
for (const pm of ['pnpm', 'yarn', 'bun'])
|
||||||
|
if (userAgent.startsWith(pm)) return pm
|
||||||
|
return 'npm'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the template into the target directory, renaming `_gitignore` to
|
||||||
|
* `.gitignore` (npm strips `.gitignore` files from published packages, so the
|
||||||
|
* template can't ship one under its real name), then stamps the component
|
||||||
|
* identity: the template's `wcb-button` tag, `WcbButton` class, and file
|
||||||
|
* names are rewritten to the ones derived from the package name.
|
||||||
|
* @param {string} root absolute path of the target directory
|
||||||
|
* @param {string} packageName the name to write into the scaffolded `package.json`
|
||||||
|
* @returns {{tag: string, className: string}} the stamped component identity
|
||||||
|
*/
|
||||||
|
function scaffold(root, packageName) {
|
||||||
|
const templateDir = fileURLToPath(new URL('./template', import.meta.url))
|
||||||
|
fs.cpSync(templateDir, root, { recursive: true })
|
||||||
|
fs.renameSync(path.join(root, '_gitignore'), path.join(root, '.gitignore'))
|
||||||
|
|
||||||
|
const tag = toTagName(packageName)
|
||||||
|
const className = toClassName(tag)
|
||||||
|
fs.renameSync(
|
||||||
|
path.join(root, 'src', `${TEMPLATE_TAG}.ts`),
|
||||||
|
path.join(root, 'src', `${tag}.ts`)
|
||||||
|
)
|
||||||
|
for (const file of walkFiles(root)) {
|
||||||
|
const content = fs.readFileSync(file, 'utf8')
|
||||||
|
const stamped = content
|
||||||
|
.replaceAll(TEMPLATE_CLASS, className)
|
||||||
|
.replaceAll(TEMPLATE_TAG, tag)
|
||||||
|
if (stamped !== content) fs.writeFileSync(file, stamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageJsonPath = path.join(root, 'package.json')
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
|
||||||
|
packageJson.name = packageName
|
||||||
|
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n')
|
||||||
|
|
||||||
|
return { tag, className }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the CLI: resolves the target directory from argv or a prompt,
|
||||||
|
* refuses to overwrite existing files, scaffolds, and prints next steps.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
const arg = process.argv[2]
|
||||||
|
|
||||||
|
if (arg === '--help' || arg === '-h') {
|
||||||
|
console.log(`Usage: npm create wcb@latest [directory]
|
||||||
|
|
||||||
|
Scaffolds a publishable web-component-base custom element in [directory]
|
||||||
|
(prompts when omitted; "." scaffolds into the current directory). The
|
||||||
|
component class, tag, and file names are derived from the directory name,
|
||||||
|
and the project comes with a Vite library build, a demo page, and
|
||||||
|
custom-elements.json generation set up.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetDir = arg
|
||||||
|
if (!targetDir) {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
})
|
||||||
|
const answer = await rl.question(`Project directory: (${DEFAULT_DIR}) `)
|
||||||
|
rl.close()
|
||||||
|
targetDir = answer.trim() || DEFAULT_DIR
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = path.resolve(process.cwd(), targetDir)
|
||||||
|
const packageName = toValidPackageName(
|
||||||
|
targetDir === '.' ? path.basename(root) : targetDir
|
||||||
|
)
|
||||||
|
|
||||||
|
if (fs.existsSync(root) && fs.readdirSync(root).length > 0) {
|
||||||
|
console.error(
|
||||||
|
`error: target directory "${targetDir}" exists and is not empty`
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tag } = scaffold(root, packageName)
|
||||||
|
|
||||||
|
const pm = detectPackageManager()
|
||||||
|
const run = pm === 'npm' ? 'npm run' : pm
|
||||||
|
console.log(`
|
||||||
|
Scaffolded ${packageName} in ${root} — defines <${tag}>
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
|
||||||
|
cd ${targetDir}
|
||||||
|
${pm} install
|
||||||
|
${run} dev # demo page on the Vite dev server
|
||||||
|
${run} analyze # generate custom-elements.json
|
||||||
|
${run} build:lib # library build: ESM + UMD + types in dist/
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await main()
|
||||||
36
create/package.json
Normal file
36
create/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"name": "create-wcb",
|
||||||
|
"version": "0.2.2",
|
||||||
|
"description": "Scaffold a publishable web-component-base (wcb) custom element with custom-elements.json generation already set up",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"create-wcb": "index.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"index.js",
|
||||||
|
"template",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/ayo-run/wcb.git",
|
||||||
|
"directory": "create-wcb"
|
||||||
|
},
|
||||||
|
"homepage": "https://WebComponent.io",
|
||||||
|
"keywords": [
|
||||||
|
"web components",
|
||||||
|
"web component",
|
||||||
|
"custom elements",
|
||||||
|
"custom element",
|
||||||
|
"create",
|
||||||
|
"scaffold"
|
||||||
|
],
|
||||||
|
"author": "Ayo Ayco",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/ayo-run/wcb/issues"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
create/template/README.md
Normal file
30
create/template/README.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# wcb-button
|
||||||
|
|
||||||
|
A publishable custom element built on
|
||||||
|
[web-component-base](https://webcomponent.io), scaffolded with
|
||||||
|
`npm create wcb@latest`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `npm run dev` — start the Vite dev server on the `index.html` demo page
|
||||||
|
- `npm run build` — build the demo page
|
||||||
|
- `npm run build:lib` — build the library: ESM + UMD bundles and `.d.ts`
|
||||||
|
types in `dist/`, with `web-component-base` left external (it is a
|
||||||
|
peerDependency)
|
||||||
|
- `npm run analyze` — generate `custom-elements.json` from the component's
|
||||||
|
`static props`
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
`npm publish` (or `npm pack`) runs the `prepack` script, which rebuilds the
|
||||||
|
library and regenerates `custom-elements.json` — both ship inside the
|
||||||
|
package, and the `customElements` field in `package.json` is how Storybook,
|
||||||
|
editors, and other tooling discover the manifest. In the repo itself the
|
||||||
|
manifest stays gitignored; it is a build artifact.
|
||||||
|
|
||||||
|
See the [CEM plugin guide](https://webcomponent.io/cem-plugin/) for setting
|
||||||
|
it up with Storybook and code editors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Just keep building._
|
||||||
6
create/template/_gitignore
Normal file
6
create/template/_gitignore
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
|
||||||
|
# build artifact — regenerated by `npm run analyze` (and on `npm pack`/publish,
|
||||||
|
# where it ships inside the package via the "files" field)
|
||||||
|
custom-elements.json
|
||||||
20
create/template/custom-elements-manifest.config.mjs
Normal file
20
create/template/custom-elements-manifest.config.mjs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Custom Elements Manifest config — `npm run analyze` reads this.
|
||||||
|
//
|
||||||
|
// The wcb plugin teaches the analyzer about `static props`: every key becomes
|
||||||
|
// a typed public field plus a reflected attribute in custom-elements.json,
|
||||||
|
// and wcb internals are stripped from the public surface.
|
||||||
|
// See https://webcomponent.io/cem-plugin/ for the Storybook and editor setup.
|
||||||
|
//
|
||||||
|
// `distPaths()` rewrites each module's `path` from the scanned source
|
||||||
|
// (`src/*.ts`) to the built output this package publishes (`dist/*.js`), so a
|
||||||
|
// consumer reading the manifest resolves the files they can actually import —
|
||||||
|
// only `dist/` is shipped, never `src/`. `prepack` runs `build:lib` before
|
||||||
|
// `analyze`, so `dist/` exists when the manifest is generated.
|
||||||
|
|
||||||
|
import { wcbStaticProps, distPaths } from 'web-component-base/cem-plugin'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
globs: ['src/**/*.ts'],
|
||||||
|
outdir: '.',
|
||||||
|
plugins: [wcbStaticProps(), distPaths()],
|
||||||
|
}
|
||||||
61
create/template/index.html
Normal file
61
create/template/index.html
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>wcb-button web component</title>
|
||||||
|
|
||||||
|
<!-- meta -->
|
||||||
|
<meta property="og:title" content="wcb-button web component" />
|
||||||
|
<meta property="og:description" content="A wcb-button web component" />
|
||||||
|
<meta name="description" content="A wcb-button web component" />
|
||||||
|
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
|
||||||
|
/>
|
||||||
|
<script type="module" src="./src/wcb-button.ts"></script>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
display: grid;
|
||||||
|
place-content: safe center;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
wcb-button {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1><wcb-button></h1>
|
||||||
|
<hr />
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
<p>A wcb-button web component</p>
|
||||||
|
<wcb-button label="Hello, Beautiful!"></wcb-button>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<p>
|
||||||
|
Generate <code>custom-elements.json</code> with
|
||||||
|
<code>npm run analyze</code> — more on the
|
||||||
|
<a href="https://webcomponent.io/cem-plugin/">CEM plugin guide</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<hr />
|
||||||
|
<small>
|
||||||
|
Just keep building.<br />
|
||||||
|
A <a href="https://webcomponent.io">web-component-base</a> component.
|
||||||
|
</small>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
42
create/template/package.json
Normal file
42
create/template/package.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"name": "wcb-button",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "A wcb-button web component",
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": "https://webcomponent.io",
|
||||||
|
"customElements": "custom-elements.json",
|
||||||
|
"module": "./dist/wcb-button.js",
|
||||||
|
"types": "./dist/wcb-button.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/wcb-button.d.ts",
|
||||||
|
"import": "./dist/wcb-button.js",
|
||||||
|
"require": "./dist/wcb-button.umd.cjs",
|
||||||
|
"default": "./dist/wcb-button.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"custom-elements.json",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Warning: no test specified\"",
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"build:lib": "vite build --config vite-lib.config.ts",
|
||||||
|
"analyze": "cem analyze",
|
||||||
|
"prepack": "npm run build:lib && npm run analyze"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@custom-elements-manifest/analyzer": "^0.11.0",
|
||||||
|
"@types/node": "^26.1.1",
|
||||||
|
"vite": "^8.1.5",
|
||||||
|
"vite-plugin-dts": "^5.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"web-component-base": "^6.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
create/template/pnpm-workspace.yaml
Normal file
2
create/template/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
allowBuilds:
|
||||||
|
web-component-base: false
|
||||||
40
create/template/src/wcb-button.ts
Normal file
40
create/template/src/wcb-button.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare a type for your `static props`
|
||||||
|
* It is not required for wcb to work, but rather an
|
||||||
|
* opt-in compile-time type safety with Typescript
|
||||||
|
* @see https://webcomponent.io/prop-access/#opt-in-typed-props-in-typescript
|
||||||
|
*/
|
||||||
|
type WcbButtonProps = {
|
||||||
|
label: string
|
||||||
|
disabled: boolean
|
||||||
|
clicks: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A starter component. Every key in `static props` is a reactive property
|
||||||
|
* reflected to a kebab-cased attribute — writing `this.props.clicks++`
|
||||||
|
* updates the attribute and re-renders.
|
||||||
|
*/
|
||||||
|
export class WcbButton extends WebComponent<WcbButtonProps> {
|
||||||
|
static props: WcbButtonProps = {
|
||||||
|
label: 'Click me',
|
||||||
|
disabled: false,
|
||||||
|
clicks: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
bump = () => {
|
||||||
|
if (!this.props.disabled) this.props.clicks++
|
||||||
|
}
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
return html`
|
||||||
|
<button onclick=${this.bump} disabled=${this.props.disabled}>
|
||||||
|
${this.props.label} — ${this.props.clicks}×
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('wcb-button', WcbButton)
|
||||||
21
create/template/tsconfig.json
Normal file
21
create/template/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// TypeScript config for the component source. `npm run build:lib` runs
|
||||||
|
// vite-plugin-dts against this to emit `.d.ts` files next to the bundles.
|
||||||
|
//
|
||||||
|
// `moduleResolution: "bundler"` is the load-bearing line. Without it tsc falls
|
||||||
|
// back to classic resolution, never looks in node_modules, and the
|
||||||
|
// `web-component-base` import fails — which in turn makes every `this.props`
|
||||||
|
// access an error, because the base class it resolves to is an error type.
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
28
create/template/vite-lib.config.ts
Normal file
28
create/template/vite-lib.config.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// Library build — `npm run build:lib` emits ESM + UMD bundles and .d.ts
|
||||||
|
// types into dist/, with web-component-base left external for consumers to
|
||||||
|
// provide (it is a peerDependency). The plain `vite` dev server and
|
||||||
|
// `npm run build` use the default config and the index.html demo page.
|
||||||
|
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import dts from 'vite-plugin-dts'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
publicDir: false,
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: resolve(import.meta.dirname, 'src/wcb-button.ts'),
|
||||||
|
name: 'WcbButton',
|
||||||
|
fileName: 'wcb-button',
|
||||||
|
},
|
||||||
|
rolldownOptions: {
|
||||||
|
external: ['web-component-base'],
|
||||||
|
output: {
|
||||||
|
globals: {
|
||||||
|
'web-component-base': 'web-component-base',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [dts()],
|
||||||
|
})
|
||||||
65
demo/examples/attribute-converters/index.html
Normal file
65
demo/examples/attribute-converters/index.html
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Custom attribute converters</title>
|
||||||
|
<script type="module" src="./index.js"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
|
<style>
|
||||||
|
.hint {
|
||||||
|
opacity: 0.75;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5em;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
event-card {
|
||||||
|
display: block;
|
||||||
|
padding: 0.4em 1em;
|
||||||
|
border: 2px solid #8884;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Custom attribute converters</h1>
|
||||||
|
<p>
|
||||||
|
Override <code>toAttribute</code> and <code>fromAttribute</code> when a
|
||||||
|
prop needs its own serialization, and delegate the rest to
|
||||||
|
<code>super</code>. Both take the <strong>camelCase prop key</strong>,
|
||||||
|
matching your <code>static props</code> declaration.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>A Date prop and an Array prop</h2>
|
||||||
|
<p>
|
||||||
|
<code>when</code> is a real <code>Date</code> and <code>tags</code> is a
|
||||||
|
real <code>Array</code> — reflected as an ISO date and a comma-separated
|
||||||
|
list. Each button reports the attributes the conversion produced.
|
||||||
|
</p>
|
||||||
|
<converter-demo></converter-demo>
|
||||||
|
|
||||||
|
<h2>Parsed from markup</h2>
|
||||||
|
<p>
|
||||||
|
Attributes written from outside the component are parsed through
|
||||||
|
<code>fromAttribute</code>, so these mount with a real
|
||||||
|
<code>Date</code> and a real <code>Array</code>:
|
||||||
|
</p>
|
||||||
|
<event-card when="2026-01-02" tags="alpha,beta" title="From markup"></event-card>
|
||||||
|
|
||||||
|
<h2>Why lossy conversion is safe</h2>
|
||||||
|
<p>
|
||||||
|
Reflection does <strong>not</strong> round-trip: when you assign a prop,
|
||||||
|
wcb reflects it but never parses the attribute back — the value you
|
||||||
|
assigned is already the source of truth. That is why
|
||||||
|
<code>when</code> can keep its full timestamp while the attribute carries
|
||||||
|
only the date. <code>render()</code> and <code>onChanges</code> still fire
|
||||||
|
as normal.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
127
demo/examples/attribute-converters/index.js
Normal file
127
demo/examples/attribute-converters/index.js
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `toAttribute` / `fromAttribute` are the escape hatch for props whose
|
||||||
|
* reflection doesn't fit the built-in rules. Override the props you care
|
||||||
|
* about and hand the rest to `super`.
|
||||||
|
*
|
||||||
|
* Two things worth noticing in this example:
|
||||||
|
*
|
||||||
|
* - `when` is a `Date`, so it declares a `Date` default — the prop's runtime
|
||||||
|
* type comes from the default's `typeof`, and the props proxy would reject
|
||||||
|
* a Date written to a prop declared as a string.
|
||||||
|
* - `toAttribute` is deliberately lossy (it drops the time of day), which is
|
||||||
|
* safe: a prop write is not parsed back through `fromAttribute`, so
|
||||||
|
* `props.when` keeps the exact value you assigned.
|
||||||
|
*/
|
||||||
|
class EventCard extends WebComponent {
|
||||||
|
static props = {
|
||||||
|
when: new Date('2026-07-20T09:30:00Z'),
|
||||||
|
tags: [],
|
||||||
|
title: 'Release day',
|
||||||
|
}
|
||||||
|
// renders into a shadow root so a parent's re-render can't reconcile away
|
||||||
|
// the children this component renders for itself
|
||||||
|
static shadowRootInit = { mode: 'open' }
|
||||||
|
|
||||||
|
toAttribute(name, value) {
|
||||||
|
// ISO date only — the attribute is a summary, not the whole value
|
||||||
|
if (name === 'when') return value.toISOString().slice(0, 10)
|
||||||
|
// a list reflects as a readable comma-separated attribute...
|
||||||
|
if (name === 'tags') return value.length ? value.join(',') : null
|
||||||
|
return super.toAttribute(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fromAttribute(name, value) {
|
||||||
|
if (name === 'when') return new Date(`${value}T00:00:00Z`)
|
||||||
|
// ...and parses back the same way
|
||||||
|
if (name === 'tags') return value.split(',').filter(Boolean)
|
||||||
|
return super.fromAttribute(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
const { when, tags, title } = this.props
|
||||||
|
return html`
|
||||||
|
<h3>${title}</h3>
|
||||||
|
<p>
|
||||||
|
prop <code>when</code> (a real Date):
|
||||||
|
<strong>${when.toISOString()}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
prop <code>tags</code> (a real Array):
|
||||||
|
<strong>${JSON.stringify(tags)}</strong>
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('event-card', EventCard)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panel that drives the card and shows the attribute each conversion produces.
|
||||||
|
*/
|
||||||
|
class ConverterDemo extends WebComponent {
|
||||||
|
static props = { log: '' }
|
||||||
|
|
||||||
|
afterViewInit() {
|
||||||
|
this.card = this.querySelector('event-card')
|
||||||
|
this.#report('initial mount')
|
||||||
|
}
|
||||||
|
|
||||||
|
#report(action) {
|
||||||
|
const el = this.card
|
||||||
|
if (!el) return
|
||||||
|
const attr = (name) =>
|
||||||
|
el.hasAttribute(name) ? `${name}="${el.getAttribute(name)}"` : '(absent)'
|
||||||
|
this.props.log = `${action} → ${attr('when')} · ${attr('tags')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
#setPreciseDate() {
|
||||||
|
// the time of day survives on the prop even though the attribute drops it
|
||||||
|
this.card.props.when = new Date('2026-12-25T18:45:12Z')
|
||||||
|
this.#report('props.when = 2026-12-25T18:45:12Z')
|
||||||
|
}
|
||||||
|
|
||||||
|
#setTags(tags) {
|
||||||
|
this.card.props.tags = tags
|
||||||
|
this.#report(`props.tags = ${JSON.stringify(tags)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
#writeAttribute() {
|
||||||
|
// an outside write *is* parsed through fromAttribute
|
||||||
|
this.card.setAttribute('tags', 'html,css,js')
|
||||||
|
this.#report('setAttribute("tags", "html,css,js")')
|
||||||
|
}
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
return html`
|
||||||
|
<event-card></event-card>
|
||||||
|
|
||||||
|
<p class="log"><code>${this.props.log}</code></p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button onclick=${() => this.#setPreciseDate()}>
|
||||||
|
props.when = a precise Date
|
||||||
|
</button>
|
||||||
|
<button onclick=${() => this.#setTags(['web', 'components'])}>
|
||||||
|
props.tags = ['web', 'components']
|
||||||
|
</button>
|
||||||
|
<button onclick=${() => this.#setTags([])}>
|
||||||
|
props.tags = [] (removes the attribute)
|
||||||
|
</button>
|
||||||
|
<button onclick=${() => this.#writeAttribute()}>
|
||||||
|
setAttribute("tags", "html,css,js")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">
|
||||||
|
Setting <code>tags</code> to an empty array makes
|
||||||
|
<code>toAttribute</code> return <code>null</code>, which
|
||||||
|
<strong>removes the attribute</strong> — the same mechanism that makes a
|
||||||
|
<code>false</code> boolean an absent attribute. The last button writes
|
||||||
|
the attribute from outside, which <em>is</em> parsed back through
|
||||||
|
<code>fromAttribute</code> into a real array.
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('converter-demo', ConverterDemo)
|
||||||
26
demo/examples/attribute-lifecycle/index.html
Normal file
26
demo/examples/attribute-lifecycle/index.html
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Attribute lifecycle</title>
|
||||||
|
<script type="module" src="./index.js"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Attribute lifecycle: empty string, removal, markup wins</h1>
|
||||||
|
|
||||||
|
<h2>Default (no authored attribute)</h2>
|
||||||
|
<p>Shows the declared default; try the buttons.</p>
|
||||||
|
<attr-demo id="from-default"></attr-demo>
|
||||||
|
|
||||||
|
<h2>Authored attribute wins over the default</h2>
|
||||||
|
<p>
|
||||||
|
<code><attr-demo label="from markup"></code> — the default is not
|
||||||
|
applied because the attribute is already present.
|
||||||
|
</p>
|
||||||
|
<attr-demo id="from-markup" label="from markup"></attr-demo>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
36
demo/examples/attribute-lifecycle/index.js
Normal file
36
demo/examples/attribute-lifecycle/index.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v5 attribute-change correctness:
|
||||||
|
*
|
||||||
|
* - `label=""` (empty string) stays the string `''` — it is no longer
|
||||||
|
* coerced to boolean `true` / echoed as `"true"`.
|
||||||
|
* - `removeAttribute('label')` resets the prop to its **declared default**
|
||||||
|
* instead of writing `null` and throwing before render().
|
||||||
|
* - an attribute authored in markup **wins** over the default: defaults are
|
||||||
|
* reflected on connect and skip any attribute already present.
|
||||||
|
*/
|
||||||
|
export class AttrDemo extends WebComponent {
|
||||||
|
static props = {
|
||||||
|
label: 'default-label',
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue = () => this.setAttribute('label', 'hello')
|
||||||
|
setEmpty = () => this.setAttribute('label', '')
|
||||||
|
remove = () => this.removeAttribute('label')
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
const value = this.props.label
|
||||||
|
return html`
|
||||||
|
<p>label prop: <code class="value">${JSON.stringify(value)}</code></p>
|
||||||
|
<p>typeof: <code class="type">${typeof value}</code></p>
|
||||||
|
<button class="set-value" onclick=${this.setValue}>set "hello"</button>
|
||||||
|
<button class="set-empty" onclick=${this.setEmpty}>set "" (empty)</button>
|
||||||
|
<button class="remove" onclick=${this.remove}>
|
||||||
|
removeAttribute → default
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('attr-demo', AttrDemo)
|
||||||
70
demo/examples/boolean-props/index.html
Normal file
70
demo/examples/boolean-props/index.html
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Boolean props as bare attributes</title>
|
||||||
|
<script type="module" src="./index.js"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
|
<style>
|
||||||
|
.hint {
|
||||||
|
opacity: 0.75;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5em;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.log code {
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Boolean props are bare attributes</h1>
|
||||||
|
<p>
|
||||||
|
A boolean prop follows the HTML convention in both directions:
|
||||||
|
<strong>presence means <code>true</code>, absence means
|
||||||
|
<code>false</code></strong>. Reflecting <code>true</code> sets the bare
|
||||||
|
attribute; reflecting <code>false</code> removes it. Nothing ever gets
|
||||||
|
stamped as <code>flag="false"</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Driving the prop</h2>
|
||||||
|
<p>
|
||||||
|
Every button below reports the resulting prop value and the actual
|
||||||
|
attribute. The border lights up via
|
||||||
|
<code>:host([flag])</code> — a presence selector that now matches only
|
||||||
|
when the prop is genuinely true.
|
||||||
|
</p>
|
||||||
|
<boolean-demo></boolean-demo>
|
||||||
|
|
||||||
|
<h2>Presence in markup</h2>
|
||||||
|
<p>
|
||||||
|
All three of these mount with <code>props.flag === true</code>. The last
|
||||||
|
one is the surprise worth internalizing: any present value counts,
|
||||||
|
including the literal string <code>"false"</code>, exactly like
|
||||||
|
<code><input disabled="false"></code> is still disabled.
|
||||||
|
</p>
|
||||||
|
<div class="row">
|
||||||
|
<flag-box flag></flag-box>
|
||||||
|
<flag-box flag=""></flag-box>
|
||||||
|
<flag-box flag="false"></flag-box>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Absent means false</h2>
|
||||||
|
<p>
|
||||||
|
No attribute, no <code>[flag]</code> match, and
|
||||||
|
<code>props.flag === false</code>. Removing the attribute later also
|
||||||
|
lands on <code>false</code> rather than restoring a declared default —
|
||||||
|
which is why boolean props should default to <code>false</code> and use
|
||||||
|
an inverted name (<code>disabled</code>, not <code>enabled</code>) when
|
||||||
|
you want them on by default.
|
||||||
|
</p>
|
||||||
|
<flag-box></flag-box>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
113
demo/examples/boolean-props/index.js
Normal file
113
demo/examples/boolean-props/index.js
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boolean props reflect as **bare attributes**: `true` sets the attribute with
|
||||||
|
* no value, `false` removes it entirely — exactly how native `disabled` and
|
||||||
|
* `required` behave.
|
||||||
|
*
|
||||||
|
* Two things that silently broke under the old string reflection now work:
|
||||||
|
*
|
||||||
|
* - `el.toggleAttribute(name, force)` only ever adds/removes an attribute,
|
||||||
|
* it never rewrites a value. With `flag="false"` sitting there, forcing
|
||||||
|
* `true` changed nothing — no attributeChangedCallback, no re-render.
|
||||||
|
* - `[flag]` CSS selectors match on *presence*, so a stamped `flag="false"`
|
||||||
|
* matched too and painted the "on" style onto an element whose prop was
|
||||||
|
* false.
|
||||||
|
*/
|
||||||
|
class FlagBox extends WebComponent {
|
||||||
|
static props = { flag: false }
|
||||||
|
static shadowRootInit = { mode: 'open' }
|
||||||
|
static styles = `
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: 0.8em 1em;
|
||||||
|
border: 2px solid #8884;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
/* presence selector: matches only while the prop is actually true */
|
||||||
|
:host([flag]) {
|
||||||
|
border-color: #c2410c;
|
||||||
|
background: #c2410c22;
|
||||||
|
}
|
||||||
|
.state { font-weight: 600; }
|
||||||
|
`
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
return html`
|
||||||
|
<div>flag is <span class="state">${String(this.props.flag)}</span></div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('flag-box', FlagBox)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel wires the three ways host code drives a boolean prop, and prints
|
||||||
|
* what the DOM actually looks like after each one.
|
||||||
|
*/
|
||||||
|
class BooleanDemo extends WebComponent {
|
||||||
|
static props = { log: '' }
|
||||||
|
|
||||||
|
onInit() {
|
||||||
|
this.box = null
|
||||||
|
}
|
||||||
|
|
||||||
|
afterViewInit() {
|
||||||
|
this.box = this.querySelector('flag-box')
|
||||||
|
this.#report('initial mount')
|
||||||
|
}
|
||||||
|
|
||||||
|
#report(action) {
|
||||||
|
const el = this.box
|
||||||
|
if (!el) return
|
||||||
|
const attr = el.hasAttribute('flag')
|
||||||
|
? `flag="${el.getAttribute('flag')}"`
|
||||||
|
: '(absent)'
|
||||||
|
this.props.log = `${action} → prop: ${el.props.flag} · attribute: ${attr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
#toggle() {
|
||||||
|
// the platform API — a silent no-op back when flag="false" was stamped
|
||||||
|
this.box.toggleAttribute('flag', !this.box.props.flag)
|
||||||
|
this.#report('toggleAttribute()')
|
||||||
|
}
|
||||||
|
|
||||||
|
#write(value) {
|
||||||
|
this.box.props.flag = value
|
||||||
|
this.#report(`props.flag = ${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
#stringly() {
|
||||||
|
// the pre-v6 idiom: any present value is true, so this turns the flag ON
|
||||||
|
// even though it reads as "off". wcb warns in the console when it sees it.
|
||||||
|
this.box.setAttribute('flag', 'false')
|
||||||
|
this.#report('setAttribute("flag", "false")')
|
||||||
|
}
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
return html`
|
||||||
|
<flag-box></flag-box>
|
||||||
|
|
||||||
|
<p class="log"><code>${this.props.log}</code></p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button onclick=${() => this.#toggle()}>toggleAttribute()</button>
|
||||||
|
<button onclick=${() => this.#write(true)}>props.flag = true</button>
|
||||||
|
<button onclick=${() => this.#write(false)}>props.flag = false</button>
|
||||||
|
<button onclick=${() => this.#stringly()}>
|
||||||
|
setAttribute("flag", "false") ⚠️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">
|
||||||
|
The last button is the migration trap:
|
||||||
|
<strong>any present value is true</strong>, so writing the string
|
||||||
|
<code>"false"</code> turns the flag <em>on</em> — just like native
|
||||||
|
<code>disabled="false"</code> is still disabled. Check the console for
|
||||||
|
the warning wcb logs. Use
|
||||||
|
<code>toggleAttribute(name, bool)</code> instead.
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('boolean-demo', BooleanDemo)
|
||||||
31
demo/examples/constructed-styles/index.html
Normal file
31
demo/examples/constructed-styles/index.html
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Constructable styles</title>
|
||||||
|
<script type="module" src="./index.js"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Constructable styles</h1>
|
||||||
|
<p>
|
||||||
|
<code>static styles</code> is adopted into the shadow root as a
|
||||||
|
constructable stylesheet. It takes a single string, or an array adopted in
|
||||||
|
order.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>A single string</h2>
|
||||||
|
<styled-elements type="warn" condition></styled-elements>
|
||||||
|
|
||||||
|
<h2>An array of sheets</h2>
|
||||||
|
<p>
|
||||||
|
Shared tokens, then a ready-made <code>CSSStyleSheet</code>, then local
|
||||||
|
styles — so a design system composes a base sheet with per-component CSS
|
||||||
|
instead of inlining the base in every component.
|
||||||
|
</p>
|
||||||
|
<composed-styles></composed-styles>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
73
demo/examples/constructed-styles/index.js
Normal file
73
demo/examples/constructed-styles/index.js
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
// @ts-check
|
||||||
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
|
class StyledElements extends WebComponent {
|
||||||
|
static shadowRootInit = {
|
||||||
|
mode: 'open',
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = `
|
||||||
|
div {
|
||||||
|
background-color: yellow;
|
||||||
|
border: 1px solid black;
|
||||||
|
padding: 1em;
|
||||||
|
|
||||||
|
p {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
return html`
|
||||||
|
<div>
|
||||||
|
<p>Wow!?</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('styled-elements', StyledElements)
|
||||||
|
|
||||||
|
// `static styles` also takes an array, adopted in order — a shared base sheet
|
||||||
|
// composed with per-component styles, instead of inlining the base everywhere.
|
||||||
|
const tokens = `
|
||||||
|
:host {
|
||||||
|
--demo-accent: rebeccapurple;
|
||||||
|
--demo-radius: 6px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// entries may be strings or ready-made CSSStyleSheet objects; a sheet is
|
||||||
|
// adopted as-is, so one instance can be shared by many components
|
||||||
|
const base = new CSSStyleSheet()
|
||||||
|
base.replaceSync(`
|
||||||
|
div {
|
||||||
|
border: 2px solid var(--demo-accent);
|
||||||
|
border-radius: var(--demo-radius);
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class ComposedStyles extends WebComponent {
|
||||||
|
static shadowRootInit = {
|
||||||
|
mode: 'open',
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = [
|
||||||
|
tokens,
|
||||||
|
base,
|
||||||
|
// last one wins on equal specificity
|
||||||
|
`p { color: var(--demo-accent); font-weight: 600; }`,
|
||||||
|
]
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
return html`
|
||||||
|
<div>
|
||||||
|
<p>Three sheets: tokens, a shared CSSStyleSheet, and local styles.</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('composed-styles', ComposedStyles)
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { html, WebComponent } from '../../src/index.js'
|
//@ts-check
|
||||||
|
import { html, WebComponent } from 'web-component-base'
|
||||||
|
|
||||||
export class BooleanPropTest extends WebComponent {
|
export class BooleanPropTest extends WebComponent {
|
||||||
static props = {
|
static props = {
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
import { WebComponent, html } from '../../src/index.js'
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
export class Counter extends WebComponent {
|
export class Counter extends WebComponent {
|
||||||
static props = {
|
static props = {
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
import { html, WebComponent } from '../../src/index.js'
|
import { html, WebComponent } from 'web-component-base'
|
||||||
|
|
||||||
export class HelloWorld extends WebComponent {
|
export class HelloWorld extends WebComponent {
|
||||||
static props = {
|
static props = {
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
import { html, WebComponent } from '../../src/index.js'
|
import { html, WebComponent } from 'web-component-base'
|
||||||
|
|
||||||
class SimpleText extends WebComponent {
|
class SimpleText extends WebComponent {
|
||||||
clickCallback() {
|
clickCallback() {
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { WebComponent, html } from '../../src/index.js'
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
class Toggle extends WebComponent {
|
class Toggle extends WebComponent {
|
||||||
static props = {
|
static props = {
|
||||||
|
|
@ -3,12 +3,15 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>WC demo</title>
|
<title>Kitchen sink</title>
|
||||||
<script type="module" src="./HelloWorld.mjs"></script>
|
<script type="module" src="./HelloWorld.mjs"></script>
|
||||||
<script type="module" src="./SimpleText.mjs"></script>
|
<script type="module" src="./SimpleText.mjs"></script>
|
||||||
<script type="module" src="./BooleanPropTest.mjs"></script>
|
<script type="module" src="./BooleanPropTest.mjs"></script>
|
||||||
<script type="module" src="./Counter.mjs"></script>
|
<script type="module" src="./Counter.mjs"></script>
|
||||||
<script type="module" src="./Toggle.js"></script>
|
<script type="module" src="./Toggle.js"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<my-toggle></my-toggle>
|
<my-toggle></my-toggle>
|
||||||
21
demo/examples/just-parts/index.html
Normal file
21
demo/examples/just-parts/index.html
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Using just some parts</title>
|
||||||
|
<script type="module" src="./index.js"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Using the <code>html</code> tag + <code>createElement</code> directly</h1>
|
||||||
|
<p>
|
||||||
|
<code>my-quote</code> extends the vanilla <code>HTMLElement</code> — not
|
||||||
|
<code>WebComponent</code> — yet still builds its DOM from a tagged
|
||||||
|
template.
|
||||||
|
</p>
|
||||||
|
<my-quote></my-quote>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
demo/examples/just-parts/index.js
Normal file
26
demo/examples/just-parts/index.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { html } from 'web-component-base/html'
|
||||||
|
import { createElement } from 'web-component-base/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* You don't have to extend `WebComponent` to use its parts. The `html` tag and
|
||||||
|
* the `createElement` util work on a plain `HTMLElement`, so you can build the
|
||||||
|
* reactive-template behavior into your own classes.
|
||||||
|
*/
|
||||||
|
class MyQuote extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
let count = 0
|
||||||
|
const el = createElement(
|
||||||
|
html`<button
|
||||||
|
id="quote-btn"
|
||||||
|
onClick=${(e) => {
|
||||||
|
e.target.textContent = `clicked ${++count}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
click me
|
||||||
|
</button>`
|
||||||
|
)
|
||||||
|
this.appendChild(el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('my-quote', MyQuote)
|
||||||
29
demo/examples/library-comparison/README.md
Normal file
29
demo/examples/library-comparison/README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Library comparison
|
||||||
|
|
||||||
|
The counter component from the [comparison guide](https://webcomponent.io/comparison/),
|
||||||
|
implemented in each library it's measured against, plus the reproducible size
|
||||||
|
benchmark behind the guide's numbers.
|
||||||
|
|
||||||
|
- `counters/*.mjs` — the same minimal counter (one reactive `count` prop, a
|
||||||
|
click handler, a re-render on change) in `web-component-base`, Elena, Lit,
|
||||||
|
FAST, and vanilla `HTMLElement`. `index.html` runs all five live.
|
||||||
|
- `measure.mjs` — bundles each counter _with_ its library runtime (esbuild,
|
||||||
|
`--bundle --minify --format=esm`) and compresses the result with gzip (−9) and
|
||||||
|
brotli (q11) via Node's `zlib`. That's the real "cost of your first
|
||||||
|
component": library runtime + component code, everything the browser
|
||||||
|
downloads.
|
||||||
|
|
||||||
|
## Run the benchmark
|
||||||
|
|
||||||
|
From this folder:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node measure.mjs # human-readable table
|
||||||
|
node measure.mjs --md # Markdown table (what the guide embeds)
|
||||||
|
node measure.mjs --json # raw numbers
|
||||||
|
```
|
||||||
|
|
||||||
|
The external libraries are pinned dev dependencies of the `demo` workspace, and
|
||||||
|
`web-component-base` is bundled from its published `dist/` (run `pnpm build` at
|
||||||
|
the repo root first), so the numbers are deterministic. Bump a pinned version in
|
||||||
|
`demo/package.json`, re-run, and the table moves with it.
|
||||||
24
demo/examples/library-comparison/counters/elena.mjs
Normal file
24
demo/examples/library-comparison/counters/elena.mjs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// The same reactive counter, written in Elena (@elenajs/core).
|
||||||
|
// Elena's `html` has no inline event syntax, so the click handler is attached
|
||||||
|
// in `firstUpdated` (after the first render) rather than in the template.
|
||||||
|
// `count` is a non-reflected reactive prop.
|
||||||
|
import { Elena, html } from '@elenajs/core'
|
||||||
|
|
||||||
|
export class ElenaCounter extends Elena(HTMLElement) {
|
||||||
|
static tagName = 'elena-counter'
|
||||||
|
static props = [{ name: 'count', reflect: false }]
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`<button type="button">${this.count}</button>`
|
||||||
|
}
|
||||||
|
|
||||||
|
firstUpdated() {
|
||||||
|
this.element.addEventListener('click', () => {
|
||||||
|
this.count++
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ElenaCounter.define()
|
||||||
25
demo/examples/library-comparison/counters/fast.mjs
Normal file
25
demo/examples/library-comparison/counters/fast.mjs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
// The same reactive counter, written in FAST (@microsoft/fast-element).
|
||||||
|
// The buildless, non-decorator form: the template binds against the element
|
||||||
|
// instance (`x`), and `count` is declared as a number attribute.
|
||||||
|
import {
|
||||||
|
FASTElement,
|
||||||
|
html,
|
||||||
|
nullableNumberConverter,
|
||||||
|
} from '@microsoft/fast-element'
|
||||||
|
|
||||||
|
const template = html`
|
||||||
|
<button @click=${(x) => x.count++}>${(x) => x.count}</button>
|
||||||
|
`
|
||||||
|
|
||||||
|
export class FastCounter extends FASTElement {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.count = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FastCounter.define({
|
||||||
|
name: 'fast-counter',
|
||||||
|
template,
|
||||||
|
attributes: [{ property: 'count', converter: nullableNumberConverter }],
|
||||||
|
})
|
||||||
17
demo/examples/library-comparison/counters/lit.mjs
Normal file
17
demo/examples/library-comparison/counters/lit.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// The same reactive counter, written in Lit.
|
||||||
|
import { LitElement, html } from 'lit'
|
||||||
|
|
||||||
|
export class LitCounter extends LitElement {
|
||||||
|
static properties = { count: { type: Number } }
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.count = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`<button @click=${() => this.count++}>${this.count}</button>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('lit-counter', LitCounter)
|
||||||
33
demo/examples/library-comparison/counters/vanilla.mjs
Normal file
33
demo/examples/library-comparison/counters/vanilla.mjs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// The same reactive counter, written from scratch on top of HTMLElement — the
|
||||||
|
// baseline every library above is measured against. No library runtime: the
|
||||||
|
// reactivity, the attribute reflection, and the re-render are all hand-rolled.
|
||||||
|
export class VanillaCounter extends HTMLElement {
|
||||||
|
static observedAttributes = ['count']
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
if (!this.hasAttribute('count')) this.setAttribute('count', '0')
|
||||||
|
this.render()
|
||||||
|
this.addEventListener('click', () => {
|
||||||
|
this.setAttribute('count', String(this.count + 1))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback() {
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
get count() {
|
||||||
|
return Number(this.getAttribute('count')) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let button = this.querySelector('button')
|
||||||
|
if (!button) {
|
||||||
|
button = document.createElement('button')
|
||||||
|
this.append(button)
|
||||||
|
}
|
||||||
|
button.textContent = String(this.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('vanilla-counter', VanillaCounter)
|
||||||
15
demo/examples/library-comparison/counters/wcb.mjs
Normal file
15
demo/examples/library-comparison/counters/wcb.mjs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// The same reactive counter, written in web-component-base.
|
||||||
|
// One reactive `count` prop, a click handler, a re-render on change.
|
||||||
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
|
export class WcbCounter extends WebComponent {
|
||||||
|
static props = { count: 0 }
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
return html`
|
||||||
|
<button onClick=${() => ++this.props.count}>${this.props.count}</button>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('wcb-counter', WcbCounter)
|
||||||
231
demo/examples/library-comparison/index.html
Normal file
231
demo/examples/library-comparison/index.html
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Library comparison — the same counter, measured</title>
|
||||||
|
<script type="module" src="./counters/vanilla.mjs"></script>
|
||||||
|
<script type="module" src="./counters/wcb.mjs"></script>
|
||||||
|
<script type="module" src="./counters/elena.mjs"></script>
|
||||||
|
<script type="module" src="./counters/lit.mjs"></script>
|
||||||
|
<script type="module" src="./counters/fast.mjs"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
|
<style>
|
||||||
|
.counters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 1em;
|
||||||
|
margin: 1.5em 0;
|
||||||
|
}
|
||||||
|
.counter-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--card);
|
||||||
|
padding: 1em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6em;
|
||||||
|
}
|
||||||
|
.counter-card .lib {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.counter-card .size {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.counter-card .size strong {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.counter-card button {
|
||||||
|
font-size: 1.4em;
|
||||||
|
padding: 0.3em 0.8em;
|
||||||
|
min-width: 3em;
|
||||||
|
}
|
||||||
|
.win {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
figcaption {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-top: 0.4em;
|
||||||
|
}
|
||||||
|
table.sizes {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
table.sizes th,
|
||||||
|
table.sizes td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.5em 0.75em;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
table.sizes th:first-child,
|
||||||
|
table.sizes td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
table.sizes thead th {
|
||||||
|
background: var(--tag);
|
||||||
|
}
|
||||||
|
table.sizes tbody tr:first-child {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.repro {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4em;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>The same counter, in each library</h1>
|
||||||
|
<p>
|
||||||
|
This is the component behind the
|
||||||
|
<a href="https://webcomponent.io/comparison/">comparison guide</a>: one
|
||||||
|
minimal counter — a single reactive <code>count</code> prop, a click
|
||||||
|
handler, a re-render on change — implemented in
|
||||||
|
<code>web-component-base</code> and in each library it's measured against.
|
||||||
|
Every counter below is live; click it. The source of each is listed at
|
||||||
|
the bottom of the page, and the
|
||||||
|
<a href="#methodology">measurement that produces the sizes</a> is
|
||||||
|
<code>measure.mjs</code>, runnable from this folder.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="counters">
|
||||||
|
<figure class="counter-card">
|
||||||
|
<span class="lib">web-component-base</span>
|
||||||
|
<span class="size"><strong>2.6 kB</strong> brotli</span>
|
||||||
|
<wcb-counter></wcb-counter>
|
||||||
|
<figcaption><code>static props</code> + <code>html</code></figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="counter-card">
|
||||||
|
<span class="lib">Elena</span>
|
||||||
|
<span class="size"><strong>3.4 kB</strong> brotli</span>
|
||||||
|
<elena-counter></elena-counter>
|
||||||
|
<figcaption>click handler wired in <code>firstUpdated</code></figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="counter-card">
|
||||||
|
<span class="lib">Lit</span>
|
||||||
|
<span class="size"><strong>5.3 kB</strong> brotli</span>
|
||||||
|
<lit-counter></lit-counter>
|
||||||
|
<figcaption><code>static properties</code>, shadow DOM</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="counter-card">
|
||||||
|
<span class="lib">FAST</span>
|
||||||
|
<span class="size"><strong>12.2 kB</strong> brotli</span>
|
||||||
|
<fast-counter></fast-counter>
|
||||||
|
<figcaption>binding directives, shadow DOM</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="counter-card">
|
||||||
|
<span class="lib">vanilla <code>HTMLElement</code></span>
|
||||||
|
<span class="size"><strong>~0.2 kB</strong> brotli</span>
|
||||||
|
<vanilla-counter></vanilla-counter>
|
||||||
|
<figcaption>no library — the hand-rolled baseline</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="methodology">How it's measured</h2>
|
||||||
|
<p>
|
||||||
|
The number that matters for a first component is not the library's
|
||||||
|
advertised size — it's <strong>library runtime + your component code,
|
||||||
|
bundled and compressed</strong>, because that's everything the browser
|
||||||
|
actually downloads. So <code>measure.mjs</code>, for each library:
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
bundles the counter <em>with</em> its library runtime using
|
||||||
|
<code>esbuild</code> (<code>bundle: true</code>,
|
||||||
|
<code>minify: true</code>, <code>format: 'esm'</code>) — tree-shaking
|
||||||
|
away whatever that component doesn't touch;
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
compresses the bundle with <strong>gzip</strong> (level 9) and
|
||||||
|
<strong>brotli</strong> (quality 11) via Node's built-in
|
||||||
|
<code>zlib</code> — the same codecs a CDN or server serves with.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p>
|
||||||
|
The counters are byte-for-byte the same components running above and
|
||||||
|
listed under Implementation below. The external libraries are pinned dev
|
||||||
|
dependencies of this demo workspace, and <code>web-component-base</code>
|
||||||
|
is bundled from its published <code>dist/</code>, so the run is
|
||||||
|
deterministic.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Output</h2>
|
||||||
|
<p>
|
||||||
|
Measured at the pinned versions below (WCB from this repo's build,
|
||||||
|
esbuild <code>0.27</code>, Node <code>zlib</code>):
|
||||||
|
</p>
|
||||||
|
<table class="sizes">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Library</th>
|
||||||
|
<th>Version</th>
|
||||||
|
<th>Minified</th>
|
||||||
|
<th>Gzip</th>
|
||||||
|
<th>Brotli</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>web-component-base</td>
|
||||||
|
<td>6.1.4</td>
|
||||||
|
<td>6.6 kB</td>
|
||||||
|
<td>2.9 kB</td>
|
||||||
|
<td>2.6 kB</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>@elenajs/core</td>
|
||||||
|
<td>1.0.0</td>
|
||||||
|
<td>9.1 kB</td>
|
||||||
|
<td>3.7 kB</td>
|
||||||
|
<td>3.4 kB</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>lit</td>
|
||||||
|
<td>3.3.3</td>
|
||||||
|
<td>15.3 kB</td>
|
||||||
|
<td>5.9 kB</td>
|
||||||
|
<td>5.3 kB</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>@microsoft/fast-element</td>
|
||||||
|
<td>3.0.1</td>
|
||||||
|
<td>44.8 kB</td>
|
||||||
|
<td>13.6 kB</td>
|
||||||
|
<td>12.2 kB</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>vanilla <code>HTMLElement</code></td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>0.6 kB</td>
|
||||||
|
<td>0.3 kB</td>
|
||||||
|
<td>0.2 kB</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
The WCB counter is the smallest real component here — about
|
||||||
|
<strong>23% under Elena, 52% under Lit, and 79% under FAST</strong> once
|
||||||
|
compressed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Reproduce it</h2>
|
||||||
|
<p class="repro">
|
||||||
|
From <code>demo/examples/library-comparison/</code>:
|
||||||
|
<code>node measure.mjs</code> for the table,
|
||||||
|
<code>node measure.mjs --md</code> for Markdown, or
|
||||||
|
<code>node measure.mjs --json</code> for raw numbers.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Bump a pinned version in <code>demo/package.json</code>, re-run, and the
|
||||||
|
numbers move with it — the benchmark is the source of truth, not this
|
||||||
|
page.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
125
demo/examples/library-comparison/measure.mjs
Normal file
125
demo/examples/library-comparison/measure.mjs
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
// Reproducible size benchmark behind the comparison guide's numbers.
|
||||||
|
//
|
||||||
|
// For each library it bundles the *same* counter component
|
||||||
|
// (`counters/<lib>.mjs`) together with that library's runtime, minifies with
|
||||||
|
// esbuild, and compresses the result with gzip (level 9) and brotli (quality
|
||||||
|
// 11) using Node's built-in zlib. That is the real "cost of your first
|
||||||
|
// component": everything the browser downloads for one reactive element.
|
||||||
|
//
|
||||||
|
// node measure.mjs # human-readable table
|
||||||
|
// node measure.mjs --md # a Markdown table (what the guide embeds)
|
||||||
|
// node measure.mjs --json # machine-readable JSON
|
||||||
|
//
|
||||||
|
// The external libraries are pinned dev dependencies of this demo workspace, so
|
||||||
|
// the run is deterministic; `web-component-base` is bundled from its published
|
||||||
|
// build output (`dist/`) rather than the workspace source, to measure exactly
|
||||||
|
// what consumers install.
|
||||||
|
import { build } from 'esbuild'
|
||||||
|
import { gzipSync, brotliCompressSync, constants } from 'node:zlib'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import process from 'node:process'
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const demoRoot = resolve(here, '../..')
|
||||||
|
|
||||||
|
// Resolve `web-component-base` to its built entry (the published artifact), not
|
||||||
|
// the workspace `src/` the Vite dev server aliases it to.
|
||||||
|
const wcbDist = resolve(here, '../../../dist/index.js')
|
||||||
|
const wcbAlias = {
|
||||||
|
name: 'wcb-dist',
|
||||||
|
setup(b) {
|
||||||
|
b.onResolve({ filter: /^web-component-base$/ }, () => ({ path: wcbDist }))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not every library exposes `./package.json` in its `exports` map, so read the
|
||||||
|
// installed manifest straight from the demo's `node_modules`.
|
||||||
|
const version = (pkg) =>
|
||||||
|
JSON.parse(
|
||||||
|
readFileSync(resolve(demoRoot, 'node_modules', pkg, 'package.json'), 'utf8')
|
||||||
|
).version
|
||||||
|
|
||||||
|
const wcbVersion = JSON.parse(
|
||||||
|
readFileSync(resolve(here, '../../../package.json'), 'utf8')
|
||||||
|
).version
|
||||||
|
|
||||||
|
const targets = [
|
||||||
|
{ name: 'web-component-base', file: 'wcb.mjs', version: wcbVersion },
|
||||||
|
{
|
||||||
|
name: '@elenajs/core',
|
||||||
|
file: 'elena.mjs',
|
||||||
|
version: version('@elenajs/core'),
|
||||||
|
},
|
||||||
|
{ name: 'lit', file: 'lit.mjs', version: version('lit') },
|
||||||
|
{
|
||||||
|
name: '@microsoft/fast-element',
|
||||||
|
file: 'fast.mjs',
|
||||||
|
version: version('@microsoft/fast-element'),
|
||||||
|
},
|
||||||
|
{ name: 'vanilla HTMLElement', file: 'vanilla.mjs', version: '-' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const gzip = (buf) => gzipSync(buf, { level: 9 }).length
|
||||||
|
const brotli = (buf) =>
|
||||||
|
brotliCompressSync(buf, {
|
||||||
|
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
|
||||||
|
}).length
|
||||||
|
|
||||||
|
const kb = (n) => (n / 1000).toFixed(1) + ' kB'
|
||||||
|
|
||||||
|
const rows = []
|
||||||
|
for (const t of targets) {
|
||||||
|
const result = await build({
|
||||||
|
entryPoints: [resolve(here, 'counters', t.file)],
|
||||||
|
bundle: true,
|
||||||
|
minify: true,
|
||||||
|
format: 'esm',
|
||||||
|
write: false,
|
||||||
|
plugins: [wcbAlias],
|
||||||
|
logLevel: 'silent',
|
||||||
|
})
|
||||||
|
const code = result.outputFiles[0].contents
|
||||||
|
rows.push({
|
||||||
|
name: t.name,
|
||||||
|
version: t.version,
|
||||||
|
minified: code.length,
|
||||||
|
gzip: gzip(code),
|
||||||
|
brotli: brotli(code),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const arg = process.argv[2]
|
||||||
|
|
||||||
|
if (arg === '--json') {
|
||||||
|
console.log(JSON.stringify(rows, null, 2))
|
||||||
|
} else if (arg === '--md') {
|
||||||
|
console.log('| Library | Version | Minified | Gzip | Brotli |')
|
||||||
|
console.log('| ------- | ------- | -------- | ---- | ------ |')
|
||||||
|
for (const r of rows) {
|
||||||
|
console.log(
|
||||||
|
`| ${r.name} | ${r.version} | ${kb(r.minified)} | ${kb(r.gzip)} | ${kb(r.brotli)} |`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const pad = (s, n) => String(s).padEnd(n)
|
||||||
|
const lpad = (s, n) => String(s).padStart(n)
|
||||||
|
console.log(
|
||||||
|
pad('Library', 26),
|
||||||
|
pad('Version', 9),
|
||||||
|
lpad('Minified', 10),
|
||||||
|
lpad('Gzip', 8),
|
||||||
|
lpad('Brotli', 8)
|
||||||
|
)
|
||||||
|
console.log('-'.repeat(63))
|
||||||
|
for (const r of rows) {
|
||||||
|
console.log(
|
||||||
|
pad(r.name, 26),
|
||||||
|
pad(r.version, 9),
|
||||||
|
lpad(kb(r.minified), 10),
|
||||||
|
lpad(kb(r.gzip), 8),
|
||||||
|
lpad(kb(r.brotli), 8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
27
demo/examples/lifecycle-order/index.html
Normal file
27
demo/examples/lifecycle-order/index.html
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Lifecycle order & buffering</title>
|
||||||
|
<script type="module" src="./index.js"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>onInit runs before the first render</h1>
|
||||||
|
|
||||||
|
<h2>With an authored attribute</h2>
|
||||||
|
<p>
|
||||||
|
<code><lifecycle-order label="authored"></code> — note that
|
||||||
|
<code>onInit</code> already sees <code>"authored"</code>, the single
|
||||||
|
<code>render</code> runs after it, and the pre-connect change is not
|
||||||
|
replayed through <code>onChanges</code>.
|
||||||
|
</p>
|
||||||
|
<lifecycle-order id="authored" label="authored"></lifecycle-order>
|
||||||
|
|
||||||
|
<h2>Without an authored attribute (uses the default)</h2>
|
||||||
|
<lifecycle-order id="default"></lifecycle-order>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
57
demo/examples/lifecycle-order/index.js
Normal file
57
demo/examples/lifecycle-order/index.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v5 buffering guarantee. Per the Custom Elements spec, an element upgraded
|
||||||
|
* with authored attributes (`<lifecycle-order label="authored">`) fires
|
||||||
|
* `attributeChangedCallback` **before** `connectedCallback`. Taken literally
|
||||||
|
* that means `render()` could run before `onInit()`.
|
||||||
|
*
|
||||||
|
* WebComponent buffers pre-connect attribute changes: the prop value is applied
|
||||||
|
* immediately (so `this.props` is correct inside `onInit`), but the `render()`
|
||||||
|
* and `onChanges()` side effects are deferred until after `onInit`. The order
|
||||||
|
* on connect is always: onInit → render → afterViewInit, and pre-connect
|
||||||
|
* changes are NOT replayed through onChanges.
|
||||||
|
*
|
||||||
|
* This component records the order it observes and surfaces it in the page.
|
||||||
|
*/
|
||||||
|
export class LifecycleOrder extends WebComponent {
|
||||||
|
static props = {
|
||||||
|
label: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
#log = []
|
||||||
|
|
||||||
|
onInit() {
|
||||||
|
// props already reflect any authored attribute here, before the first render
|
||||||
|
this.#log.push(`onInit (props.label = "${this.props.label}")`)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.#log.push(`render (props.label = "${this.props.label}")`)
|
||||||
|
super.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
onChanges(changes) {
|
||||||
|
this.#log.push(
|
||||||
|
`onChanges (${changes.attribute} → "${changes.currentValue}")`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterViewInit() {
|
||||||
|
this.#log.push('afterViewInit')
|
||||||
|
const list = document.createElement('ol')
|
||||||
|
list.className = 'lifecycle-log'
|
||||||
|
for (const line of this.#log) {
|
||||||
|
const li = document.createElement('li')
|
||||||
|
li.textContent = line
|
||||||
|
list.appendChild(li)
|
||||||
|
}
|
||||||
|
this.after(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
return html`<p>label: ${this.props.label}</p>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('lifecycle-order', LifecycleOrder)
|
||||||
61
demo/examples/nested-composition/index.html
Normal file
61
demo/examples/nested-composition/index.html
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Nested composition</title>
|
||||||
|
<script type="module" src="./index.js"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
|
<style>
|
||||||
|
.board {
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
padding: 1em;
|
||||||
|
max-width: 32em;
|
||||||
|
}
|
||||||
|
.board header {
|
||||||
|
display: flex;
|
||||||
|
gap: 1em;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75em;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.4em;
|
||||||
|
}
|
||||||
|
.row-name {
|
||||||
|
min-width: 5em;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Nested composition</h1>
|
||||||
|
<p class="lede">
|
||||||
|
A component within a component within a component. Each
|
||||||
|
<code><tick-counter></code> lives inside a
|
||||||
|
<code><counter-row></code>, which lives inside a
|
||||||
|
<code><counter-board></code> — three levels, all light DOM.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>Click a few counters so their counts differ.</li>
|
||||||
|
<li>
|
||||||
|
Click <strong>Rename board</strong>. That changes only the board's
|
||||||
|
title and re-renders the <em>root</em> component.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The counts stay exactly as you left them. A nested component owns the
|
||||||
|
DOM it renders for itself; an ancestor re-render patches props down but
|
||||||
|
never trims the inner subtree.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<counter-board></counter-board>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
76
demo/examples/nested-composition/index.js
Normal file
76
demo/examples/nested-composition/index.js
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
// @ts-check
|
||||||
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composition: a component within a component within a component.
|
||||||
|
*
|
||||||
|
* The three classes below nest three levels deep, all rendering to light DOM.
|
||||||
|
* The point of interest is what happens to the inner subtrees when an *outer*
|
||||||
|
* component re-renders for a reason unrelated to them.
|
||||||
|
*
|
||||||
|
* A parent's vnode never describes what a nested component rendered for
|
||||||
|
* itself — `<tick-counter></tick-counter>` is empty in the row's template even
|
||||||
|
* though the counter fills itself with a button and a count. The in-place
|
||||||
|
* reconciler therefore treats a nested custom element as opaque: it patches the
|
||||||
|
* element's props (so data still flows down) but leaves the element's own
|
||||||
|
* children alone. Reconciling them would trim away the child's rendered content
|
||||||
|
* on every ancestor re-render and leave it blank until it next re-rendered on
|
||||||
|
* its own.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Level 3 (leaf): owns its own count entirely — no prop feeds it. */
|
||||||
|
class TickCounter extends WebComponent {
|
||||||
|
static props = { label: 'tick' }
|
||||||
|
#count = 0
|
||||||
|
get template() {
|
||||||
|
return html`
|
||||||
|
<button onclick=${() => this.#bump()}>
|
||||||
|
${this.props.label}: <strong class="count">${this.#count}</strong>
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
#bump() {
|
||||||
|
this.#count++
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('tick-counter', TickCounter)
|
||||||
|
|
||||||
|
/** Level 2 (middle): a labelled row that hosts a leaf counter. */
|
||||||
|
class CounterRow extends WebComponent {
|
||||||
|
static props = { name: 'row' }
|
||||||
|
get template() {
|
||||||
|
return html`
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-name">${this.props.name}</span>
|
||||||
|
<tick-counter label=${this.props.name}></tick-counter>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('counter-row', CounterRow)
|
||||||
|
|
||||||
|
/** Level 1 (root): a board that re-renders on an unrelated "rename". */
|
||||||
|
class CounterBoard extends WebComponent {
|
||||||
|
static props = { title: 'Board A' }
|
||||||
|
get template() {
|
||||||
|
return html`
|
||||||
|
<section class="board">
|
||||||
|
<header>
|
||||||
|
<h3 id="board-title">${this.props.title}</h3>
|
||||||
|
<button id="rename" onclick=${() => this.#rename()}>
|
||||||
|
Rename board (re-renders the root)
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<counter-row name="alpha"></counter-row>
|
||||||
|
<counter-row name="bravo"></counter-row>
|
||||||
|
<counter-row name="charlie"></counter-row>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
#renamed = 0
|
||||||
|
#rename() {
|
||||||
|
this.props.title = `Board ${String.fromCharCode(66 + (++this.#renamed % 25))}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('counter-board', CounterBoard)
|
||||||
20
demo/examples/on-changes/index.html
Normal file
20
demo/examples/on-changes/index.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>onChanges: property vs attribute</title>
|
||||||
|
<script type="module" src="./index.js"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>onChanges: property (camelCase) vs attribute (kebab-case)</h1>
|
||||||
|
<p>
|
||||||
|
Click <em>Rename</em> to change the <code>my-name</code> attribute and
|
||||||
|
watch the <code>onChanges</code> payload below.
|
||||||
|
</p>
|
||||||
|
<change-logger></change-logger>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
55
demo/examples/on-changes/index.js
Normal file
55
demo/examples/on-changes/index.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v5 `onChanges` payload: the `changes` object cleanly separates the
|
||||||
|
* camelCase **property** key from the kebab-case **attribute** name.
|
||||||
|
*
|
||||||
|
* - `property` → camelCase prop key, matching `this.props` access (`myName`)
|
||||||
|
* - `attribute` → kebab-case attribute name that changed (`my-name`)
|
||||||
|
* - `previousValue` / `currentValue`
|
||||||
|
*
|
||||||
|
* Before v5, `property` held the kebab-case attribute name. Code that read
|
||||||
|
* `changes.property` for the attribute name must now read `changes.attribute`.
|
||||||
|
*/
|
||||||
|
export class ChangeLogger extends WebComponent {
|
||||||
|
static props = {
|
||||||
|
myName: 'World',
|
||||||
|
}
|
||||||
|
|
||||||
|
#last = null
|
||||||
|
|
||||||
|
onChanges(changes) {
|
||||||
|
this.#last = changes
|
||||||
|
console.log('>>> onChanges', changes)
|
||||||
|
// onChanges fires after render(), so re-render to surface the payload
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
rename = () => {
|
||||||
|
this.props.myName = this.props.myName === 'World' ? 'Ayo' : 'World'
|
||||||
|
}
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
const c = this.#last
|
||||||
|
return html`
|
||||||
|
<button id="rename" onclick=${this.rename}>Rename</button>
|
||||||
|
<p id="greeting">Hello ${this.props.myName}</p>
|
||||||
|
${c
|
||||||
|
? html`
|
||||||
|
<dl>
|
||||||
|
<dt>property (camelCase)</dt>
|
||||||
|
<dd id="property">${c.property}</dd>
|
||||||
|
<dt>attribute (kebab-case)</dt>
|
||||||
|
<dd id="attribute">${c.attribute}</dd>
|
||||||
|
<dt>previousValue</dt>
|
||||||
|
<dd id="previous">${c.previousValue}</dd>
|
||||||
|
<dt>currentValue</dt>
|
||||||
|
<dd id="current">${c.currentValue}</dd>
|
||||||
|
</dl>
|
||||||
|
`
|
||||||
|
: html`<p id="no-changes">No changes yet</p>`}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('change-logger', ChangeLogger)
|
||||||
|
|
@ -3,12 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>WC demo</title>
|
<title>Single-file pen (CDN)</title>
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
font-size: larger;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import {
|
import {
|
||||||
WebComponent,
|
WebComponent,
|
||||||
|
|
@ -43,6 +38,9 @@
|
||||||
customElements.define('my-counter', Counter)
|
customElements.define('my-counter', Counter)
|
||||||
customElements.define('my-toggle', Toggle)
|
customElements.define('my-toggle', Toggle)
|
||||||
</script>
|
</script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { html, WebComponent } from '../../src/index.js'
|
import { html, WebComponent } from 'web-component-base'
|
||||||
|
|
||||||
export class HelloWorld extends WebComponent {
|
export class HelloWorld extends WebComponent {
|
||||||
static props = {
|
static props = {
|
||||||
|
|
@ -3,9 +3,12 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>WC demo</title>
|
<title>Props blueprint</title>
|
||||||
<script type="module" src="./index.js"></script>
|
<script type="module" src="./index.js"></script>
|
||||||
<script type="module" src="./hello-world.js"></script>
|
<script type="module" src="./hello-world.js"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<my-counter></my-counter>
|
<my-counter></my-counter>
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { html, WebComponent } from '../../src/index.js'
|
import { html, WebComponent } from 'web-component-base'
|
||||||
|
|
||||||
export class Counter extends WebComponent {
|
export class Counter extends WebComponent {
|
||||||
static props = {
|
static props = {
|
||||||
83
demo/examples/render-reconciliation/index.html
Normal file
83
demo/examples/render-reconciliation/index.html
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Render reconciliation</title>
|
||||||
|
<script type="module" src="./index.js"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
|
<style>
|
||||||
|
.hint {
|
||||||
|
opacity: 0.75;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.seg-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25em;
|
||||||
|
}
|
||||||
|
.seg.selected {
|
||||||
|
outline: 2px solid currentColor;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
#list li {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75em;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.4em;
|
||||||
|
}
|
||||||
|
.row-label {
|
||||||
|
min-width: 6em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Render reconciliation</h1>
|
||||||
|
<p class="lede">
|
||||||
|
Re-renders of an <code>html</code> template patch the existing DOM in
|
||||||
|
place instead of rebuilding it, so transient DOM state on the rendered
|
||||||
|
nodes survives. Each section below demonstrates one part of that.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. Focus, caret and uncommitted value survive</h2>
|
||||||
|
<p>
|
||||||
|
A controlled field: every keystroke writes back to a prop and re-renders
|
||||||
|
the component, yet the <code><input></code> is the same element
|
||||||
|
throughout.
|
||||||
|
</p>
|
||||||
|
<controlled-input></controlled-input>
|
||||||
|
|
||||||
|
<h2>2. Attribute-only changes patch in place</h2>
|
||||||
|
<p>
|
||||||
|
Selecting a tab changes only <code>aria-selected</code> and
|
||||||
|
<code>class</code>. Reach a tab with the keyboard, press Enter, and focus
|
||||||
|
stays on it — no node was replaced.
|
||||||
|
</p>
|
||||||
|
<segmented-control></segmented-control>
|
||||||
|
|
||||||
|
<h2>3. Running CSS transitions are not restarted</h2>
|
||||||
|
<p>
|
||||||
|
The box animates for 2s while the component re-renders ten times. Its
|
||||||
|
vnode is unchanged, so the element is left untouched and the transition
|
||||||
|
runs to completion.
|
||||||
|
</p>
|
||||||
|
<transition-safe></transition-safe>
|
||||||
|
|
||||||
|
<h2>4. Removed props and attributes are undone</h2>
|
||||||
|
<p>
|
||||||
|
Props that disappear from the new tree are actively reset on the reused
|
||||||
|
element — a dropped <code>disabled</code>, <code>data-*</code> attribute
|
||||||
|
and inline <code>style</code> rules all go away.
|
||||||
|
</p>
|
||||||
|
<prop-removal></prop-removal>
|
||||||
|
|
||||||
|
<h2>5. Lists — and the non-keyed limitation</h2>
|
||||||
|
<p>
|
||||||
|
Appending and removing reuses the surviving rows. Reordering, however, is
|
||||||
|
matched <em>by position</em>: the rendered result is correct, but
|
||||||
|
preserved state stays with the slot rather than following the item.
|
||||||
|
</p>
|
||||||
|
<item-list></item-list>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
221
demo/examples/render-reconciliation/index.js
Normal file
221
demo/examples/render-reconciliation/index.js
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
// @ts-check
|
||||||
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. A controlled text field.
|
||||||
|
*
|
||||||
|
* Every keystroke writes back to a prop, which re-renders the component. The
|
||||||
|
* `<input>` element is reused by the reconciler, so focus, the caret position
|
||||||
|
* and the typed-but-uncommitted value all survive the re-render. Before
|
||||||
|
* in-place patching this rebuilt the input and kicked the user out of the field
|
||||||
|
* on the first character.
|
||||||
|
*/
|
||||||
|
class ControlledInput extends WebComponent {
|
||||||
|
static props = { value: '' }
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
return html`
|
||||||
|
<label for="field"
|
||||||
|
>Type here — focus and caret survive every render</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="field"
|
||||||
|
type="text"
|
||||||
|
placeholder="start typing..."
|
||||||
|
oninput=${(e) => (this.props.value = e.target.value)}
|
||||||
|
/>
|
||||||
|
<p>
|
||||||
|
Prop value: <output id="echo">${this.props.value || '(empty)'}</output>
|
||||||
|
</p>
|
||||||
|
<p class="hint">Renders: <span id="renders">${this.#renders}</span></p>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
#renders = 0
|
||||||
|
render() {
|
||||||
|
this.#renders++
|
||||||
|
super.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('controlled-input', ControlledInput)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2. Attribute-only changes patch in place.
|
||||||
|
*
|
||||||
|
* Selecting a tab changes nothing but `aria-selected` and `class` on the
|
||||||
|
* existing buttons — the elements themselves are reused, so a tab you reached
|
||||||
|
* with the keyboard keeps focus after selection. Tab into the strip and use
|
||||||
|
* the arrow-free Tab/Enter or Space to see focus persist.
|
||||||
|
*/
|
||||||
|
class SegmentedControl extends WebComponent {
|
||||||
|
static props = { value: 'one' }
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
const tab = (id, label) => html`
|
||||||
|
<button
|
||||||
|
id=${id}
|
||||||
|
class=${this.props.value === id ? 'seg selected' : 'seg'}
|
||||||
|
aria-selected=${String(this.props.value === id)}
|
||||||
|
onclick=${() => (this.props.value = id)}
|
||||||
|
>
|
||||||
|
${label}
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
return html`
|
||||||
|
<div role="tablist" class="seg-group">
|
||||||
|
${tab('one', 'One')} ${tab('two', 'Two')} ${tab('three', 'Three')}
|
||||||
|
</div>
|
||||||
|
<p>Selected: <output id="selected">${this.props.value}</output></p>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('segmented-control', SegmentedControl)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3. Unrelated re-renders don't restart a CSS transition.
|
||||||
|
*
|
||||||
|
* The box runs a 2s width transition. Bumping the counter re-renders the whole
|
||||||
|
* component every 200ms, but the box's vnode is unchanged, so its element is
|
||||||
|
* left completely untouched and the transition keeps running instead of
|
||||||
|
* snapping back to the start.
|
||||||
|
*/
|
||||||
|
class TransitionSafe extends WebComponent {
|
||||||
|
static props = { ticks: 0, wide: false }
|
||||||
|
|
||||||
|
// self-contained so the transition is guaranteed present wherever the
|
||||||
|
// component is used — including the e2e spec, which loads no page CSS
|
||||||
|
static shadowRootInit = { mode: 'open' }
|
||||||
|
|
||||||
|
static styles = `
|
||||||
|
:host { display: block; }
|
||||||
|
.anim-box {
|
||||||
|
width: 4em;
|
||||||
|
height: 2.5em;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.5;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: width 2s ease-in-out;
|
||||||
|
}
|
||||||
|
.anim-box.wide { width: 90%; }
|
||||||
|
`
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
id="box"
|
||||||
|
class=${this.props.wide ? 'anim-box wide' : 'anim-box'}
|
||||||
|
></div>
|
||||||
|
<p>
|
||||||
|
Unrelated re-renders while it animates:
|
||||||
|
<output id="ticks">${this.props.ticks}</output>
|
||||||
|
</p>
|
||||||
|
<button id="run" onclick=${() => this.#run()}>Animate + re-render</button>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
#run() {
|
||||||
|
this.props.wide = !this.props.wide
|
||||||
|
// hammer the component with unrelated re-renders during the transition
|
||||||
|
let n = 0
|
||||||
|
const id = setInterval(() => {
|
||||||
|
this.props.ticks++
|
||||||
|
if (++n >= 10) clearInterval(id)
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('transition-safe', TransitionSafe)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4. Prop and attribute removal.
|
||||||
|
*
|
||||||
|
* A prop that disappears from the new vnode is actively undone, not left
|
||||||
|
* behind: a dropped `disabled` goes back to `false`, a dropped `data-*`
|
||||||
|
* attribute is removed, and dropped `style` rules are cleared.
|
||||||
|
*/
|
||||||
|
class PropRemoval extends WebComponent {
|
||||||
|
// the demo starts decorated, but the boolean prop still defaults to `false`
|
||||||
|
// and carries the inverted name — absence has to mean both "false" and
|
||||||
|
// "default", which only works when they coincide
|
||||||
|
static props = { plain: false }
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
const on = !this.props.plain
|
||||||
|
// spread so the props are genuinely *absent* when off — that's the removal
|
||||||
|
// path: present in the old vnode, gone from the new one
|
||||||
|
const decoration = on
|
||||||
|
? {
|
||||||
|
disabled: true,
|
||||||
|
'data-badge': 'decorated',
|
||||||
|
style: { outline: '2px solid currentColor', padding: '0.6em' },
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
return html`
|
||||||
|
<button id="target" ...${decoration}>target element</button>
|
||||||
|
<p class="hint">
|
||||||
|
<code>disabled</code>, <code>data-badge</code> and the inline
|
||||||
|
<code>style</code> are present only while decorated — toggling removes
|
||||||
|
them from the same element instead of building a new one.
|
||||||
|
</p>
|
||||||
|
<button id="toggle" onclick=${() => (this.props.plain = on)}>
|
||||||
|
${on ? 'Remove props' : 'Add props'}
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('prop-removal', PropRemoval)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5. Lists — including the non-keyed limitation.
|
||||||
|
*
|
||||||
|
* Growing and shrinking reuses the surviving rows. But patching is index-based,
|
||||||
|
* so a *reorder* matches nodes by position, not identity: the DOM ends up
|
||||||
|
* correct, yet each row keeps its slot and has its content rewritten. Type into
|
||||||
|
* a row, then reorder, and you'll see the caret stay on the position rather
|
||||||
|
* than follow the item.
|
||||||
|
*/
|
||||||
|
class ItemList extends WebComponent {
|
||||||
|
static props = { items: ['alpha', 'bravo', 'charlie'] }
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
return html`
|
||||||
|
<ul id="list">
|
||||||
|
${this.props.items.map(
|
||||||
|
(item) => html`
|
||||||
|
<li>
|
||||||
|
<span class="row-label">${item}</span>
|
||||||
|
<input class="row-note" placeholder="note for ${item}" />
|
||||||
|
</li>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
id="add"
|
||||||
|
onclick=${() => this.#set([...this.props.items, 'delta'])}
|
||||||
|
>
|
||||||
|
Append
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="drop"
|
||||||
|
onclick=${() => this.#set(this.props.items.slice(0, -1))}
|
||||||
|
>
|
||||||
|
Remove last
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="reverse"
|
||||||
|
onclick=${() => this.#set([...this.props.items].reverse())}
|
||||||
|
>
|
||||||
|
Reverse (shows the non-keyed limitation)
|
||||||
|
</button>
|
||||||
|
<p class="hint">
|
||||||
|
Type a note into the first row, then press <b>Reverse</b>: the labels
|
||||||
|
swap but your note stays in row 1, because rows are matched by position.
|
||||||
|
A <code>key</code> prop would be needed to make state follow the item.
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
#set(items) {
|
||||||
|
this.props.items = items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('item-list', ItemList)
|
||||||
22
demo/examples/strict-props/index.html
Normal file
22
demo/examples/strict-props/index.html
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>strictProps type enforcement</title>
|
||||||
|
<script type="module" src="./index.js"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Prop type enforcement</h1>
|
||||||
|
<p>
|
||||||
|
By default a type violation is logged (see the console) and skipped. With
|
||||||
|
<code>static strictProps = true</code> it throws a <code>TypeError</code>.
|
||||||
|
</p>
|
||||||
|
<lenient-counter></lenient-counter>
|
||||||
|
<hr />
|
||||||
|
<strict-counter></strict-counter>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
70
demo/examples/strict-props/index.js
Normal file
70
demo/examples/strict-props/index.js
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v5 derives a prop's type from its `static props` default and, by default,
|
||||||
|
* treats a type violation as **loud but non-fatal**: the bad write is logged
|
||||||
|
* via `console.error` and skipped, so the prop keeps its value and
|
||||||
|
* `render()` / `onChanges()` still run.
|
||||||
|
*
|
||||||
|
* Here `count` is typed `number` (default `0`). Assigning a string is skipped.
|
||||||
|
*/
|
||||||
|
export class LenientCounter extends WebComponent {
|
||||||
|
static props = { count: 0 }
|
||||||
|
|
||||||
|
#status = 'ok'
|
||||||
|
|
||||||
|
bump = () => ++this.props.count
|
||||||
|
|
||||||
|
violate = () => {
|
||||||
|
this.props.count = 'not-a-number' // logged + skipped; count stays a number
|
||||||
|
this.#status = `count is still ${this.props.count} (${typeof this.props.count})`
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
return html`
|
||||||
|
<h3>Default: log & skip</h3>
|
||||||
|
<button id="lenient-bump" onclick=${this.bump}>
|
||||||
|
count = ${this.props.count}
|
||||||
|
</button>
|
||||||
|
<button id="lenient-violate" onclick=${this.violate}>
|
||||||
|
Assign a string
|
||||||
|
</button>
|
||||||
|
<p id="lenient-status">${this.#status}</p>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teams wanting hard enforcement opt in with `static strictProps = true`.
|
||||||
|
* The same violation now throws a `TypeError` instead of being skipped.
|
||||||
|
*/
|
||||||
|
export class StrictCounter extends WebComponent {
|
||||||
|
static strictProps = true
|
||||||
|
static props = { count: 0 }
|
||||||
|
|
||||||
|
#status = 'ok'
|
||||||
|
|
||||||
|
violate = () => {
|
||||||
|
try {
|
||||||
|
this.props.count = 'not-a-number'
|
||||||
|
this.#status = 'no error (unexpected)'
|
||||||
|
} catch (e) {
|
||||||
|
this.#status = `threw: ${e.name}`
|
||||||
|
}
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
return html`
|
||||||
|
<h3>strictProps = true</h3>
|
||||||
|
<button id="strict-violate" onclick=${this.violate}>
|
||||||
|
Assign a string
|
||||||
|
</button>
|
||||||
|
<p id="strict-status">${this.#status}</p>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('lenient-counter', LenientCounter)
|
||||||
|
customElements.define('strict-counter', StrictCounter)
|
||||||
15
demo/examples/style-objects/index.html
Normal file
15
demo/examples/style-objects/index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Style objects</title>
|
||||||
|
<script type="module" src="./index.js"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<styled-elements type="warn" condition></styled-elements>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
import { WebComponent, html } from '../../src/index.js'
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
class StyledElements extends WebComponent {
|
class StyledElements extends WebComponent {
|
||||||
static props = {
|
static props = {
|
||||||
|
|
@ -3,9 +3,12 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>WC demo</title>
|
<title>Templating</title>
|
||||||
<script type="module" src="./index.js"></script>
|
<script type="module" src="./index.js"></script>
|
||||||
<script type="module" src="./with-lit.js"></script>
|
<script type="module" src="./with-lit.js"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h2>With our html</h2>
|
<h2>With our html</h2>
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
import { WebComponent, html } from '../../src/index.js'
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
export class Counter extends WebComponent {
|
export class Counter extends WebComponent {
|
||||||
static props = {
|
static props = {
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { WebComponent } from '../../src/index.js'
|
import { WebComponent } from 'web-component-base'
|
||||||
import {
|
import {
|
||||||
html,
|
html,
|
||||||
render as lit,
|
render as lit,
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
import { html, WebComponent } from '../../src/index.js'
|
import { html, WebComponent } from 'web-component-base'
|
||||||
|
|
||||||
export class Counter extends WebComponent {
|
export class Counter extends WebComponent {
|
||||||
static props = {
|
static props = {
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { html, WebComponent } from '../../src/index.js'
|
import { html, WebComponent } from 'web-component-base'
|
||||||
|
|
||||||
export class HelloWorld extends WebComponent {
|
export class HelloWorld extends WebComponent {
|
||||||
static props = {
|
static props = {
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { html, WebComponent } from '../../src/index.js'
|
import { html, WebComponent } from 'web-component-base'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: rendering currently wipes all children so focus gets removed on fields
|
* TODO: rendering currently wipes all children so focus gets removed on fields
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
import { html, WebComponent } from '../../src/index.js'
|
import { html, WebComponent } from 'web-component-base'
|
||||||
|
|
||||||
export class Toggle extends WebComponent {
|
export class Toggle extends WebComponent {
|
||||||
static props = {
|
static props = {
|
||||||
|
|
@ -4,16 +4,14 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>WC demo</title>
|
<title>Typed props</title>
|
||||||
<script type="module" src="./Counter.mjs"></script>
|
<script type="module" src="./Counter.mjs"></script>
|
||||||
<script type="module" src="./Toggle.mjs"></script>
|
<script type="module" src="./Toggle.mjs"></script>
|
||||||
<script type="module" src="./HelloWorld.mjs"></script>
|
<script type="module" src="./HelloWorld.mjs"></script>
|
||||||
<script type="module" src="./Object.mjs"></script>
|
<script type="module" src="./Object.mjs"></script>
|
||||||
<style>
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
* {
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
font-size: larger;
|
<script type="module" src="../../shell.js"></script>
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -29,7 +27,7 @@
|
||||||
* TODO: fix using custom events
|
* TODO: fix using custom events
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// import { attachEffect } from '../../src/index.js'
|
// import { attachEffect } from 'web-component-base'
|
||||||
// const myObjectEl = document.querySelector('my-object')
|
// const myObjectEl = document.querySelector('my-object')
|
||||||
// const objectProp = myObjectEl.props.object
|
// const objectProp = myObjectEl.props.object
|
||||||
// const displayPanelEl = document.querySelector('#display-panel')
|
// const displayPanelEl = document.querySelector('#display-panel')
|
||||||
52
demo/examples/typed-props/index.html
Normal file
52
demo/examples/typed-props/index.html
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Compile-time prop types</title>
|
||||||
|
<script type="module" src="./index.ts"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
|
<style>
|
||||||
|
.btn.primary { font-weight: 600; }
|
||||||
|
.btn.secondary { opacity: 0.85; }
|
||||||
|
.btn.ghost { background: transparent; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Compile-time prop types</h1>
|
||||||
|
<p>
|
||||||
|
Declare the shape as a named type and pass it as the class type
|
||||||
|
argument — <code>extends WebComponent<TypedButtonProps></code> —
|
||||||
|
and <code>this.props.*</code> is checked against it instead of being
|
||||||
|
<code>any</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<typed-button></typed-button>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The buttons above are ordinary reactive props; the point of this example
|
||||||
|
is what the <em>compiler</em> now sees. In
|
||||||
|
<code>index.ts</code> below, <code>this.props.variant</code> is
|
||||||
|
<code>string</code>, <code>this.props.disabled</code> is
|
||||||
|
<code>boolean</code>, and each line in <code>CompileErrors</code> is a
|
||||||
|
real type error kept honest by <code>@ts-expect-error</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<h2>Without the type argument</h2>
|
||||||
|
<p>
|
||||||
|
Omitting it keeps the previous behavior: every read is <code>any</code>,
|
||||||
|
so a typo like <code>this.props.varient</code> compiles fine.
|
||||||
|
</p>
|
||||||
|
<untyped-button></untyped-button>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Runtime is unchanged either way.</strong> Types are erased before
|
||||||
|
the browser sees this file — <code>static strictProps</code> is still what
|
||||||
|
guards values arriving from attributes at runtime.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
97
demo/examples/typed-props/index.ts
Normal file
97
demo/examples/typed-props/index.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
|
// the type above, the defaults within: unions stay narrow with nothing to
|
||||||
|
// cast, and the annotation checks the defaults themselves too
|
||||||
|
type TypedButtonProps = {
|
||||||
|
variant: 'primary' | 'secondary' | 'ghost'
|
||||||
|
disabled: boolean
|
||||||
|
clicks: number
|
||||||
|
}
|
||||||
|
|
||||||
|
class TypedButton extends WebComponent<TypedButtonProps> {
|
||||||
|
static props: TypedButtonProps = {
|
||||||
|
variant: 'primary',
|
||||||
|
disabled: false,
|
||||||
|
clicks: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
bump = () => {
|
||||||
|
if (!this.props.disabled) this.props.clicks++
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle = () => (this.props.disabled = !this.props.disabled)
|
||||||
|
|
||||||
|
cycle = () => {
|
||||||
|
const order = ['primary', 'secondary', 'ghost'] as const
|
||||||
|
const next = order[(order.indexOf(this.props.variant) + 1) % order.length]
|
||||||
|
this.props.variant = next
|
||||||
|
}
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
// Each read below is typed by the declaration above — hover them in an
|
||||||
|
// editor to see the variant union, `boolean` and `number`.
|
||||||
|
const variant: TypedButtonProps['variant'] = this.props.variant
|
||||||
|
const disabled: boolean = this.props.disabled
|
||||||
|
const clicks: number = this.props.clicks
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<button
|
||||||
|
id="typed-button"
|
||||||
|
class="btn ${variant}"
|
||||||
|
disabled=${disabled}
|
||||||
|
onclick=${this.bump}
|
||||||
|
>
|
||||||
|
${variant} — clicked ${clicks}×
|
||||||
|
</button>
|
||||||
|
<button id="typed-cycle" onclick=${this.cycle}>Cycle variant</button>
|
||||||
|
<button id="typed-toggle" onclick=${this.toggle}>
|
||||||
|
${disabled ? 'Enable' : 'Disable'}
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERROR examples
|
||||||
|
* `@ts-expect-error` keeps this example working
|
||||||
|
*/
|
||||||
|
class CompileErrors extends WebComponent<TypedButtonProps> {
|
||||||
|
static props: TypedButtonProps = {
|
||||||
|
variant: 'primary',
|
||||||
|
disabled: false,
|
||||||
|
clicks: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
demo() {
|
||||||
|
// if you remove the ts-expect-error comment below, the editor should show red squiggly lines
|
||||||
|
// @ts-expect-error string is not assignable to boolean
|
||||||
|
this.props.disabled = 'yes'
|
||||||
|
|
||||||
|
// @ts-expect-error 'plaid' is not in the variant union
|
||||||
|
this.props.variant = 'plaid'
|
||||||
|
|
||||||
|
// @ts-expect-error 'varient' is a typo — not a declared prop
|
||||||
|
this.props.varient
|
||||||
|
|
||||||
|
// @ts-expect-error clicks is a number, not a string
|
||||||
|
this.props.clicks.toUpperCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Omitting the type argument keeps the previous behavior — every read is
|
||||||
|
* `any`, so none of the mistakes above are caught. This still compiles.
|
||||||
|
*/
|
||||||
|
class UntypedButton extends WebComponent {
|
||||||
|
static props = { variant: 'primary' }
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
this.props.varient // no error: `any` swallows the typo
|
||||||
|
return html`<button class="btn">${this.props.variant} (untyped)</button>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('typed-button', TypedButton)
|
||||||
|
customElements.define('untyped-button', UntypedButton)
|
||||||
|
|
||||||
|
export { CompileErrors }
|
||||||
18
demo/examples/typed-props/tsconfig.json
Normal file
18
demo/examples/typed-props/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
// typecheck against the emitted `.d.ts` — the same types a consumer gets.
|
||||||
|
// (Vite aliases this specifier to `src/` at runtime; see demo/vite.config.js)
|
||||||
|
"paths": {
|
||||||
|
"web-component-base": ["../../../dist/index.d.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["./index.ts"]
|
||||||
|
}
|
||||||
16
demo/examples/use-shadow/index.html
Normal file
16
demo/examples/use-shadow/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Shadow DOM</title>
|
||||||
|
<script type="module" src="./index.js"></script>
|
||||||
|
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="../../shell.css" />
|
||||||
|
<script type="module" src="../../shell.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>With our html</h2>
|
||||||
|
<my-counter></my-counter>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
import { WebComponent, html } from '../../src/index.js'
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
export class Counter extends WebComponent {
|
export class Counter extends WebComponent {
|
||||||
static props = {
|
static props = {
|
||||||
172
demo/index.html
Normal file
172
demo/index.html
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>web-component-base — examples</title>
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
document.documentElement.dataset.theme =
|
||||||
|
localStorage.getItem('wcb-theme') ||
|
||||||
|
(matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light')
|
||||||
|
} catch (e) {}
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="./shell.css" />
|
||||||
|
<script type="module" src="./shell.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="home-header">
|
||||||
|
<h1><code>web-component-base</code> examples</h1>
|
||||||
|
<p>
|
||||||
|
A live gallery of the demos using web components. Each
|
||||||
|
card opens a standalone example page.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h2 class="section-label">Library comparison</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<a class="card" href="./examples/library-comparison/index.html">
|
||||||
|
<div class="name">
|
||||||
|
The same counter, measured<span class="tag">sizes</span>
|
||||||
|
</div>
|
||||||
|
<div class="desc">
|
||||||
|
One counter in WCB, Elena, Lit, FAST, and vanilla — live side by side,
|
||||||
|
with the reproducible <code>measure.mjs</code> behind the comparison
|
||||||
|
guide's numbers.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-label">v6 behavior demos</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<a class="card" href="./examples/boolean-props/index.html">
|
||||||
|
<div class="name">
|
||||||
|
Boolean props<span class="tag">breaking</span>
|
||||||
|
</div>
|
||||||
|
<div class="desc">
|
||||||
|
Presence/absence reflection: <code>toggleAttribute</code> and
|
||||||
|
<code>[flag]</code> selectors work, and any present value is
|
||||||
|
<code>true</code>.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="./examples/attribute-converters/index.html">
|
||||||
|
<div class="name">Custom attribute converters</div>
|
||||||
|
<div class="desc">
|
||||||
|
Override <code>toAttribute</code>/<code>fromAttribute</code> for Date
|
||||||
|
and Array props; <code>null</code> removes the attribute.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-label">v5 behavior demos</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<a class="card" href="./examples/on-changes/index.html">
|
||||||
|
<div class="name">onChanges payload</div>
|
||||||
|
<div class="desc">
|
||||||
|
camelCase <code>property</code> vs kebab <code>attribute</code> (v5
|
||||||
|
breaking change).
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="./examples/strict-props/index.html">
|
||||||
|
<div class="name">Prop type enforcement</div>
|
||||||
|
<div class="desc">
|
||||||
|
Default log-and-skip vs <code>static strictProps</code> throwing.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="./examples/typed-props/index.html">
|
||||||
|
<div class="name">Compile-time prop types<span class="tag">TS</span></div>
|
||||||
|
<div class="desc">
|
||||||
|
<code>extends WebComponent<typeof props></code> infers
|
||||||
|
<code>this.props.*</code> from the defaults.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="./examples/attribute-lifecycle/index.html">
|
||||||
|
<div class="name">Attribute lifecycle</div>
|
||||||
|
<div class="desc">
|
||||||
|
Empty string stays <code>''</code>, removal resets to default, markup
|
||||||
|
wins.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="./examples/lifecycle-order/index.html">
|
||||||
|
<div class="name">Lifecycle order</div>
|
||||||
|
<div class="desc">
|
||||||
|
<code>onInit</code> runs before the first render (buffering
|
||||||
|
guarantee).
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-label">Core examples</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<a class="card" href="./examples/demo/index.html">
|
||||||
|
<div class="name">Kitchen sink</div>
|
||||||
|
<div class="desc">
|
||||||
|
Counter, toggle, hello-world, boolean props, onDestroy.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="./examples/type-restore/index.html">
|
||||||
|
<div class="name">Typed props</div>
|
||||||
|
<div class="desc">number, boolean, string, and object round-trips.</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="./examples/props-blueprint/index.html">
|
||||||
|
<div class="name">Props blueprint</div>
|
||||||
|
<div class="desc">
|
||||||
|
Non-zero defaults and camelCase↔kebab mapping.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="./examples/templating/index.html">
|
||||||
|
<div class="name">Templating</div>
|
||||||
|
<div class="desc">
|
||||||
|
Lists, links, style attributes — plus a lit-html variant.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="./examples/render-reconciliation/index.html">
|
||||||
|
<div class="name">Render reconciliation</div>
|
||||||
|
<div class="desc">
|
||||||
|
In-place patching: focus, caret, transitions and prop removal.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="./examples/nested-composition/index.html">
|
||||||
|
<div class="name">Nested composition</div>
|
||||||
|
<div class="desc">
|
||||||
|
Components three levels deep keep their own DOM across an ancestor
|
||||||
|
re-render.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="./examples/style-objects/index.html">
|
||||||
|
<div class="name">Style objects</div>
|
||||||
|
<div class="desc">Computed, conditional inline styles.</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="./examples/use-shadow/index.html">
|
||||||
|
<div class="name">Shadow DOM</div>
|
||||||
|
<div class="desc">Opt in with <code>static shadowRootInit</code>.</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="./examples/constructed-styles/index.html">
|
||||||
|
<div class="name">Constructable styles</div>
|
||||||
|
<div class="desc">
|
||||||
|
<code>static styles</code> via adopted stylesheets.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="./examples/just-parts/index.html">
|
||||||
|
<div class="name">Just the parts</div>
|
||||||
|
<div class="desc">
|
||||||
|
<code>html</code> + <code>createElement</code> on a vanilla element.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a class="card" href="./examples/pens/counter-toggle.html">
|
||||||
|
<div class="name">Single-file pen<span class="tag">CDN</span></div>
|
||||||
|
<div class="desc">
|
||||||
|
One self-contained HTML file importing from a CDN.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Run with <code>pnpm --filter demo dev</code>. Source lives in
|
||||||
|
<code>demo/examples/</code>. See the
|
||||||
|
<a href="https://webcomponent.io">documentation</a>.
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
demo/package.json
Normal file
23
demo/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "demo",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "Vite showcase app for web-component-base examples",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"shiki": "^4.3.1",
|
||||||
|
"web-component-base": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@elenajs/core": "1.0.0",
|
||||||
|
"@microsoft/fast-element": "3.0.1",
|
||||||
|
"esbuild": "^0.27.2",
|
||||||
|
"lit": "3.3.3",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
383
demo/shell.css
Normal file
383
demo/shell.css
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
/*
|
||||||
|
* Single shared stylesheet for the demo app shell.
|
||||||
|
* Linked by the landing page and every example page so the whole showcase
|
||||||
|
* shares one set of design tokens and component styles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Dark is the default (and the no-JS fallback). The theme toggle overrides it
|
||||||
|
by setting `data-theme` on <html>; when unset, the OS preference wins. */
|
||||||
|
:root {
|
||||||
|
--bg: #0b0d12;
|
||||||
|
--card: #151922;
|
||||||
|
--card-hover: #1c2230;
|
||||||
|
--surface: #10141c;
|
||||||
|
--border: #2a3140;
|
||||||
|
--text: #e6e9ef;
|
||||||
|
--muted: #97a0b0;
|
||||||
|
--accent: #7c9cff;
|
||||||
|
--tag: #263042;
|
||||||
|
--radius: 12px;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light tokens, shared by the OS preference (no explicit choice) and the
|
||||||
|
explicit `data-theme="light"` override. */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root:not([data-theme]) {
|
||||||
|
--bg: #f6f7f9;
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-hover: #ffffff;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--border: #e2e5ea;
|
||||||
|
--text: #1a1d24;
|
||||||
|
--muted: #5b6472;
|
||||||
|
--accent: #3355dd;
|
||||||
|
--tag: #eef1f7;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme='light'] {
|
||||||
|
--bg: #f6f7f9;
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-hover: #ffffff;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--border: #e2e5ea;
|
||||||
|
--text: #1a1d24;
|
||||||
|
--muted: #5b6472;
|
||||||
|
--accent: #3355dd;
|
||||||
|
--tag: #eef1f7;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-bleed background on <html>, centered content column on <body>. */
|
||||||
|
html {
|
||||||
|
background: var(--bg);
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1.25rem 4rem;
|
||||||
|
color: var(--text);
|
||||||
|
font:
|
||||||
|
16px/1.6 system-ui,
|
||||||
|
-apple-system,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- shared app header (injected on example pages) ---------- */
|
||||||
|
app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-bottom: 0.9rem;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
app-header .brand {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
app-header .crumb {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
app-header .spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
app-header .back {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
app-header .back:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- theme toggle (in the nav / landing header) ---------- */
|
||||||
|
.theme-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 0.15s,
|
||||||
|
border-color 0.15s,
|
||||||
|
background 0.15s;
|
||||||
|
}
|
||||||
|
.theme-toggle:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.theme-toggle svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- typography ---------- */
|
||||||
|
h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin: 2rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
max-width: 62ch;
|
||||||
|
}
|
||||||
|
.lede {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 1.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- interactive bits used across the examples ---------- */
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background 0.15s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--card-hover);
|
||||||
|
}
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-width: 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.35rem 1rem;
|
||||||
|
align-items: baseline;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem 1.15rem;
|
||||||
|
max-width: 34rem;
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol.lifecycle-log {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.9rem 0.9rem 0.9rem 2.4rem;
|
||||||
|
max-width: 34rem;
|
||||||
|
}
|
||||||
|
ol.lifecycle-log li {
|
||||||
|
margin: 0.15rem 0;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Give custom-element hosts a bit of breathing room in the column. */
|
||||||
|
change-logger,
|
||||||
|
attr-demo,
|
||||||
|
lenient-counter,
|
||||||
|
strict-counter,
|
||||||
|
lifecycle-order,
|
||||||
|
my-object,
|
||||||
|
my-quote {
|
||||||
|
display: block;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- source preview (shown on every example page) ---------- */
|
||||||
|
.source {
|
||||||
|
margin-top: 2.75rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.source-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
details.source-file {
|
||||||
|
/* `--code-bg` is set from Shiki's theme background once highlighting loads,
|
||||||
|
so the whole block matches the code area (falls back to the card color). */
|
||||||
|
background: var(--code-bg, var(--card));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
details.source-file > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
details.source-file[open] > summary {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
/* Box styles shared by the plain fallback and Shiki's <pre class="shiki">.
|
||||||
|
Both are transparent so the single block background (above) shows through —
|
||||||
|
one uniform color across the filename bar and the code, with no seams. */
|
||||||
|
details.source-file pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
tab-size: 2;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
details.source-file pre.source-plain {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
details.source-file pre.source-plain code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
/* Shiki dual-theme: light token colors are inline; flip to the dark theme when
|
||||||
|
the effective theme is dark. The source preview only renders with JS, which
|
||||||
|
always sets a concrete `data-theme`, so key the flip off that attribute
|
||||||
|
(background is handled by the block, not per span). */
|
||||||
|
:root[data-theme='dark'] details.source-file {
|
||||||
|
background: var(--code-bg-dark, var(--card));
|
||||||
|
}
|
||||||
|
:root[data-theme='dark'] details.source-file .shiki span {
|
||||||
|
color: var(--shiki-dark) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- landing page ---------- */
|
||||||
|
.home-header {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.home-header .theme-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.home-header p {
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 46rem;
|
||||||
|
}
|
||||||
|
.home-header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
.home-header h1 code {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--accent);
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.section-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 2.5rem 0 1rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
a.card {
|
||||||
|
display: block;
|
||||||
|
padding: 1.1rem 1.15rem;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s,
|
||||||
|
transform 0.15s;
|
||||||
|
}
|
||||||
|
a.card:hover {
|
||||||
|
background: var(--card-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
a.card .name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
a.card .desc {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--tag);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
margin-top: 3rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
283
demo/shell.js
Normal file
283
demo/shell.js
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
import { WebComponent } from 'web-component-base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared chrome for every example page — itself built with
|
||||||
|
* `web-component-base` (dogfooding). It renders into light DOM, so the shared
|
||||||
|
* `shell.css` styles it like everything else.
|
||||||
|
*/
|
||||||
|
class AppHeader extends WebComponent {
|
||||||
|
static props = { heading: '' }
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
return `
|
||||||
|
<a class="brand" href="/">web-component-base</a>
|
||||||
|
<span class="crumb">${this.props.heading}</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<a class="back" href="/">← All examples</a>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('app-header', AppHeader)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Theme toggle
|
||||||
|
//
|
||||||
|
// The initial theme is applied by a tiny inline <head> script (so there is no
|
||||||
|
// flash of the wrong theme). Here we build the toggle control, keep it in sync,
|
||||||
|
// and follow the OS while the user hasn't made an explicit choice.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const THEME_KEY = 'wcb-theme'
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
const effectiveTheme = () =>
|
||||||
|
document.documentElement.dataset.theme ||
|
||||||
|
(prefersDark.matches ? 'dark' : 'light')
|
||||||
|
|
||||||
|
const SUN_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>`
|
||||||
|
const MOON_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`
|
||||||
|
|
||||||
|
const themeToggle = document.createElement('button')
|
||||||
|
themeToggle.type = 'button'
|
||||||
|
themeToggle.className = 'theme-toggle'
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
function syncToggle() {
|
||||||
|
const dark = effectiveTheme() === 'dark'
|
||||||
|
themeToggle.innerHTML = dark ? SUN_ICON : MOON_ICON
|
||||||
|
const label = `Switch to ${dark ? 'light' : 'dark'} theme`
|
||||||
|
themeToggle.setAttribute('aria-label', label)
|
||||||
|
themeToggle.title = label
|
||||||
|
}
|
||||||
|
|
||||||
|
themeToggle.addEventListener('click', () => {
|
||||||
|
const next = effectiveTheme() === 'dark' ? 'light' : 'dark'
|
||||||
|
document.documentElement.dataset.theme = next
|
||||||
|
try {
|
||||||
|
localStorage.setItem(THEME_KEY, next)
|
||||||
|
} catch {
|
||||||
|
/* private mode / storage disabled — the choice just won't persist */
|
||||||
|
}
|
||||||
|
syncToggle()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Follow OS changes only while the user hasn't chosen explicitly.
|
||||||
|
prefersDark.addEventListener('change', () => {
|
||||||
|
let stored = null
|
||||||
|
try {
|
||||||
|
stored = localStorage.getItem(THEME_KEY)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (!stored) {
|
||||||
|
document.documentElement.dataset.theme = prefersDark.matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light'
|
||||||
|
}
|
||||||
|
syncToggle()
|
||||||
|
})
|
||||||
|
|
||||||
|
syncToggle()
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Source preview
|
||||||
|
//
|
||||||
|
// Every example page shows the source of its own implementation. Vite inlines
|
||||||
|
// each file's raw text at build time via `?raw` glob imports; we look them up
|
||||||
|
// by the example's folder (taken from the URL) so this works identically in dev
|
||||||
|
// and in the hashed production build (unlike reading rewritten <script src>s).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const RAW_JS = import.meta.glob('./examples/**/*.{js,mjs,ts}', {
|
||||||
|
query: '?raw',
|
||||||
|
import: 'default',
|
||||||
|
eager: true,
|
||||||
|
})
|
||||||
|
const RAW_HTML = import.meta.glob('./examples/**/*.html', {
|
||||||
|
query: '?raw',
|
||||||
|
import: 'default',
|
||||||
|
eager: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const basename = (path) => path.split('/').pop()
|
||||||
|
|
||||||
|
// Shiki highlighter, created once and lazily. Uses the JavaScript regex engine
|
||||||
|
// so no WASM is fetched at runtime, and only the langs/themes we actually need.
|
||||||
|
let highlighterPromise
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
function getHighlighter() {
|
||||||
|
if (!highlighterPromise) {
|
||||||
|
highlighterPromise = (async () => {
|
||||||
|
const [{ createHighlighterCore }, { createJavaScriptRegexEngine }] =
|
||||||
|
await Promise.all([
|
||||||
|
import('shiki/core'),
|
||||||
|
import('shiki/engine/javascript'),
|
||||||
|
])
|
||||||
|
const [js, ts, htmlLang, css, dark, light] = await Promise.all([
|
||||||
|
import('shiki/langs/javascript.mjs'),
|
||||||
|
import('shiki/langs/typescript.mjs'),
|
||||||
|
import('shiki/langs/html.mjs'),
|
||||||
|
import('shiki/langs/css.mjs'),
|
||||||
|
import('shiki/themes/github-dark.mjs'),
|
||||||
|
import('shiki/themes/github-light.mjs'),
|
||||||
|
])
|
||||||
|
return createHighlighterCore({
|
||||||
|
engine: createJavaScriptRegexEngine(),
|
||||||
|
langs: [js.default, ts.default, htmlLang.default, css.default],
|
||||||
|
themes: [dark.default, light.default],
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
return highlighterPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const langFor = (name) => {
|
||||||
|
if (name.endsWith('.html')) return 'html'
|
||||||
|
if (name.endsWith('.ts')) return 'typescript'
|
||||||
|
return 'javascript'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-file pens have no separate JS module — show their HTML instead, minus
|
||||||
|
// the injected shell tags so only the actual example remains.
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param html
|
||||||
|
*/
|
||||||
|
function stripShell(html) {
|
||||||
|
return html
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => !line.includes('shell.css') && !line.includes('shell.js'))
|
||||||
|
.join('\n')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ts-check strip comments
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param js
|
||||||
|
*/
|
||||||
|
function stripTsCheck(js) {
|
||||||
|
return js
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => !line.includes('ts-check'))
|
||||||
|
.join('\n')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param folder
|
||||||
|
*/
|
||||||
|
function sourcesForFolder(folder) {
|
||||||
|
const prefix = `./examples/${folder}/`
|
||||||
|
const jsKeys = Object.keys(RAW_JS)
|
||||||
|
.filter((k) => k.startsWith(prefix))
|
||||||
|
.sort()
|
||||||
|
if (jsKeys.length) {
|
||||||
|
return jsKeys.map((k) => ({
|
||||||
|
name: basename(k),
|
||||||
|
code: stripTsCheck(RAW_JS[k]),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return Object.keys(RAW_HTML)
|
||||||
|
.filter((k) => k.startsWith(prefix))
|
||||||
|
.sort()
|
||||||
|
.map((k) => ({ name: basename(k), code: stripShell(RAW_HTML[k]) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
async function renderSourcePreview() {
|
||||||
|
const match = location.pathname.match(/\/examples\/([^/]+)\//)
|
||||||
|
if (!match) return
|
||||||
|
const files = sourcesForFolder(match[1])
|
||||||
|
if (!files.length) return
|
||||||
|
|
||||||
|
const section = document.createElement('section')
|
||||||
|
section.className = 'source'
|
||||||
|
|
||||||
|
const title = document.createElement('h2')
|
||||||
|
title.className = 'source-title'
|
||||||
|
title.textContent = 'Implementation'
|
||||||
|
section.appendChild(title)
|
||||||
|
|
||||||
|
const blocks = files.map((file) => {
|
||||||
|
const details = document.createElement('details')
|
||||||
|
details.className = 'source-file'
|
||||||
|
details.open = true
|
||||||
|
|
||||||
|
const summary = document.createElement('summary')
|
||||||
|
summary.textContent = file.name
|
||||||
|
|
||||||
|
// Plain <pre><code> first; upgraded to highlighted markup once Shiki loads.
|
||||||
|
const pre = document.createElement('pre')
|
||||||
|
pre.className = 'source-plain'
|
||||||
|
const code = document.createElement('code')
|
||||||
|
code.textContent = file.code.replace(/\s+$/, '')
|
||||||
|
pre.appendChild(code)
|
||||||
|
|
||||||
|
details.append(summary, pre)
|
||||||
|
section.appendChild(details)
|
||||||
|
return { file, details, pre }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Appended at the end of <body>, after the live demo — never reparents it.
|
||||||
|
document.body.appendChild(section)
|
||||||
|
|
||||||
|
// Progressive enhancement: swap in Shiki-highlighted markup. Dual themes emit
|
||||||
|
// `--shiki` / `--shiki-dark` custom props so shell.css can flip with the OS
|
||||||
|
// color scheme. Any failure just leaves the readable plain block in place.
|
||||||
|
let highlighter
|
||||||
|
try {
|
||||||
|
highlighter = await getHighlighter()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose the theme backgrounds so the whole block (filename bar + code) can
|
||||||
|
// share Shiki's background color instead of the shell card color.
|
||||||
|
section.style.setProperty(
|
||||||
|
'--code-bg',
|
||||||
|
highlighter.getTheme('github-light').bg
|
||||||
|
)
|
||||||
|
section.style.setProperty(
|
||||||
|
'--code-bg-dark',
|
||||||
|
highlighter.getTheme('github-dark').bg
|
||||||
|
)
|
||||||
|
for (const { file, pre } of blocks) {
|
||||||
|
try {
|
||||||
|
const html = highlighter.codeToHtml(file.code.replace(/\s+$/, ''), {
|
||||||
|
lang: langFor(file.name),
|
||||||
|
themes: { light: 'github-light', dark: 'github-dark' },
|
||||||
|
})
|
||||||
|
pre.outerHTML = html
|
||||||
|
} catch {
|
||||||
|
/* keep the plain fallback for this file */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mount
|
||||||
|
//
|
||||||
|
// Example pages get the full nav (with the toggle on the right) plus a source
|
||||||
|
// preview; the landing page just gets the toggle in its header. `prepend` /
|
||||||
|
// `appendChild` only add siblings — the example's own elements are untouched.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (/\/examples\/[^/]+\//.test(location.pathname)) {
|
||||||
|
const header = document.createElement('app-header')
|
||||||
|
header.setAttribute('heading', document.title)
|
||||||
|
document.body.prepend(header)
|
||||||
|
header.appendChild(themeToggle)
|
||||||
|
renderSourcePreview()
|
||||||
|
} else {
|
||||||
|
const homeHeader = document.querySelector('.home-header')
|
||||||
|
if (homeHeader) homeHeader.appendChild(themeToggle)
|
||||||
|
else document.body.prepend(themeToggle)
|
||||||
|
}
|
||||||
52
demo/vite.config.js
Normal file
52
demo/vite.config.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { readdirSync } from 'node:fs'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const root = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const srcDir = resolve(root, '..', 'src')
|
||||||
|
|
||||||
|
// Every example page becomes a build input so `vite build` emits a full
|
||||||
|
// multi-page app (landing page + one page per example).
|
||||||
|
function examplePages() {
|
||||||
|
const inputs = { main: resolve(root, 'index.html') }
|
||||||
|
const examplesDir = resolve(root, 'examples')
|
||||||
|
for (const entry of readdirSync(examplesDir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue
|
||||||
|
for (const file of readdirSync(resolve(examplesDir, entry.name))) {
|
||||||
|
if (!file.endsWith('.html')) continue
|
||||||
|
const name = `${entry.name}/${file.replace(/\.html$/, '')}`
|
||||||
|
inputs[name] = resolve(examplesDir, entry.name, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inputs
|
||||||
|
}
|
||||||
|
|
||||||
|
// `web-component-base` is a real `workspace:*` dependency; for a build-free dev
|
||||||
|
// experience (instant HMR against the library source) we resolve it to `src`
|
||||||
|
// instead of the built `dist`. Order matters — most specific specifier first.
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: [
|
||||||
|
{
|
||||||
|
find: /^web-component-base\/utils$/,
|
||||||
|
replacement: resolve(srcDir, 'utils/index.js'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
find: /^web-component-base\/html$/,
|
||||||
|
replacement: resolve(srcDir, 'html.js'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
find: /^web-component-base$/,
|
||||||
|
replacement: resolve(srcDir, 'index.js'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// allow serving the aliased library source that lives above the demo root
|
||||||
|
fs: { allow: [resolve(root, '..')] },
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: { input: examplePages() },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -5,16 +5,17 @@ import starlight from '@astrojs/starlight'
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
redirects: {
|
redirects: {
|
||||||
'/guides/': '/guides/why',
|
'/guides/': '/getting-started',
|
||||||
|
'/api/': '/api/web-component',
|
||||||
},
|
},
|
||||||
integrations: [
|
integrations: [
|
||||||
starlight({
|
starlight({
|
||||||
title: 'WCB (alpha)',
|
title: 'WCB',
|
||||||
social: [
|
social: [
|
||||||
{
|
{
|
||||||
icon: 'npm',
|
icon: 'npm',
|
||||||
label: 'NPM',
|
label: 'NPM',
|
||||||
href: 'https://www.npmjs.com/package/web-component-base',
|
href: 'https://npmx.dev/package/web-component-base',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'sourcehut',
|
icon: 'sourcehut',
|
||||||
|
|
@ -34,6 +35,7 @@ export default defineConfig({
|
||||||
// Each item here is one entry in the navigation menu.
|
// Each item here is one entry in the navigation menu.
|
||||||
'getting-started',
|
'getting-started',
|
||||||
'why',
|
'why',
|
||||||
|
'comparison',
|
||||||
'exports',
|
'exports',
|
||||||
'usage',
|
'usage',
|
||||||
'examples',
|
'examples',
|
||||||
|
|
@ -43,16 +45,23 @@ export default defineConfig({
|
||||||
'styling',
|
'styling',
|
||||||
'just-parts',
|
'just-parts',
|
||||||
'life-cycle-hooks',
|
'life-cycle-hooks',
|
||||||
|
'cem-plugin',
|
||||||
'library-size',
|
'library-size',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// {
|
{
|
||||||
// label: 'Reference',
|
label: 'API Reference',
|
||||||
// autogenerate: { directory: 'reference' },
|
items: [
|
||||||
// },
|
'api/web-component',
|
||||||
|
'api/html',
|
||||||
|
'api/utils',
|
||||||
|
'api/cem-plugin',
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
components: {
|
components: {
|
||||||
Footer: './src/components/Attribution.astro',
|
Footer: './src/components/Attribution.astro',
|
||||||
|
Hero: './src/components/Hero.astro',
|
||||||
},
|
},
|
||||||
head: [
|
head: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
265
docs/src/components/Hero.astro
Normal file
265
docs/src/components/Hero.astro
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
---
|
||||||
|
// Override of Starlight's Hero so the splash page's visual slot holds the
|
||||||
|
// live size chart instead of an image, and the actions row carries a
|
||||||
|
// copyable `npm create wcb@latest` command chip beside the buttons
|
||||||
|
// (patterned on astro.build's hero). Structure and styles mirror
|
||||||
|
// @astrojs/starlight/components/Hero.astro (0.39.1) — a page that declares
|
||||||
|
// `hero.image` still gets the stock image treatment; pages without one get
|
||||||
|
// the chart.
|
||||||
|
import { Image } from 'astro:assets'
|
||||||
|
import { LinkButton } from '@astrojs/starlight/components'
|
||||||
|
import SizeChart from './SizeChart.astro'
|
||||||
|
|
||||||
|
// mirrors starlight's own constant; not a public export
|
||||||
|
const PAGE_TITLE_ID = '_top'
|
||||||
|
const CREATE_COMMAND = 'npm create wcb@latest'
|
||||||
|
|
||||||
|
const { data } = Astro.locals.starlightRoute.entry
|
||||||
|
const { title = data.title, tagline, image, actions = [] } = data.hero || {}
|
||||||
|
|
||||||
|
const imageAttrs = {
|
||||||
|
loading: 'eager' as const,
|
||||||
|
decoding: 'async' as const,
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
alt: image?.alt || '',
|
||||||
|
}
|
||||||
|
|
||||||
|
let darkImage: ImageMetadata | undefined
|
||||||
|
let lightImage: ImageMetadata | undefined
|
||||||
|
let rawHtml: string | undefined
|
||||||
|
if (image) {
|
||||||
|
if ('file' in image) {
|
||||||
|
darkImage = image.file
|
||||||
|
} else if ('dark' in image) {
|
||||||
|
darkImage = image.dark
|
||||||
|
lightImage = image.light
|
||||||
|
} else {
|
||||||
|
rawHtml = image.html
|
||||||
|
}
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
{
|
||||||
|
darkImage && (
|
||||||
|
<Image
|
||||||
|
src={darkImage}
|
||||||
|
{...imageAttrs}
|
||||||
|
class:list={{ 'light:sl-hidden': Boolean(lightImage) }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{lightImage && <Image src={lightImage} {...imageAttrs} class="dark:sl-hidden" />}
|
||||||
|
{rawHtml && <div class="hero-html sl-flex" set:html={rawHtml} />}
|
||||||
|
{!image && <div class="hero-chart sl-flex"><SizeChart /></div>}
|
||||||
|
<div class="sl-flex stack">
|
||||||
|
<div class="sl-flex copy">
|
||||||
|
<h1 id={PAGE_TITLE_ID} data-page-title set:html={title} />
|
||||||
|
{tagline && <div class="tagline" set:html={tagline} />}
|
||||||
|
</div>
|
||||||
|
<div class="sl-flex actions">
|
||||||
|
{
|
||||||
|
actions.map(
|
||||||
|
({ attrs: { class: className, ...attrs } = {}, icon, link: href, text, variant }) => (
|
||||||
|
<LinkButton {href} {variant} icon={icon?.name} class:list={[className]} {...attrs}>
|
||||||
|
{text}
|
||||||
|
{icon?.html && <Fragment set:html={icon.html} />}
|
||||||
|
</LinkButton>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<div class="create-command">
|
||||||
|
<code>{CREATE_COMMAND}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Copy "${CREATE_COMMAND}" to clipboard`}
|
||||||
|
data-command={CREATE_COMMAND}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="copy-icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
class="check-icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const button = document.querySelector<HTMLButtonElement>(
|
||||||
|
'.create-command button'
|
||||||
|
)
|
||||||
|
button?.addEventListener('click', async () => {
|
||||||
|
const command = button.dataset.command
|
||||||
|
if (!command) return
|
||||||
|
await navigator.clipboard.writeText(command)
|
||||||
|
button.setAttribute('data-copied', '')
|
||||||
|
setTimeout(() => button.removeAttribute('data-copied'), 1500)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero > img,
|
||||||
|
.hero > .hero-html {
|
||||||
|
object-fit: contain;
|
||||||
|
width: min(70%, 20rem);
|
||||||
|
height: auto;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the chart is a layout box, not a picture: it reads better a little wider
|
||||||
|
than the stock image slot and must not be squeezed below ~17rem */
|
||||||
|
.hero > .hero-chart {
|
||||||
|
width: min(100%, 24rem);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: clamp(1.5rem, calc(1.5rem + 1vw), 2rem);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy > * {
|
||||||
|
max-width: 50ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(var(--sl-text-3xl), calc(0.25rem + 5vw), var(--sl-text-6xl));
|
||||||
|
line-height: var(--sl-line-height-headings);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--sl-color-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
font-size: clamp(var(--sl-text-base), calc(0.0625rem + 2vw), var(--sl-text-xl));
|
||||||
|
color: var(--sl-color-gray-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* no align-items: the default stretch keeps the chip and the link
|
||||||
|
buttons the same height whenever they share a row */
|
||||||
|
.actions {
|
||||||
|
gap: 1rem 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-command {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
border: 1px solid var(--sl-color-gray-5);
|
||||||
|
border-radius: 999rem;
|
||||||
|
background-color: var(--sl-color-black);
|
||||||
|
/* natural height matches the mobile link button (40px); on wider
|
||||||
|
screens the stretch from .actions makes both 56px anyway */
|
||||||
|
padding-inline: 1rem 0.375rem;
|
||||||
|
padding-block: 0.1875rem;
|
||||||
|
font-size: var(--sl-text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-command code {
|
||||||
|
font-family: var(--__sl-font-mono);
|
||||||
|
color: var(--sl-color-gray-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-command button {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999rem;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--sl-color-gray-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-command button:hover {
|
||||||
|
color: var(--sl-color-white);
|
||||||
|
background-color: var(--sl-color-gray-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-command .check-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-command button[data-copied] .copy-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-command button[data-copied] .check-icon {
|
||||||
|
display: block;
|
||||||
|
color: var(--sl-color-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 50rem) {
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 6fr 5fr;
|
||||||
|
gap: 3%;
|
||||||
|
padding-block: clamp(2.5rem, calc(1rem + 10vmin), 10rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero > img,
|
||||||
|
.hero > .hero-html {
|
||||||
|
order: 2;
|
||||||
|
width: min(100%, 25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero > .hero-chart {
|
||||||
|
order: 2;
|
||||||
|
width: min(100%, 27rem);
|
||||||
|
margin-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
145
docs/src/components/SizeChart.astro
Normal file
145
docs/src/components/SizeChart.astro
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
---
|
||||||
|
// Hero visual: emphasis-form horizontal bar chart. WCB in the accent hue,
|
||||||
|
// every other library in the de-emphasis gray. One series, so no legend —
|
||||||
|
// each bar is direct-labeled with its name and value. Deliberately bare:
|
||||||
|
// the full methodology and feature matrix live in /comparison/.
|
||||||
|
const data = [
|
||||||
|
{ name: 'vanilla', kb: 0.2 },
|
||||||
|
{ name: 'web-component-base', kb: 2.6, accent: true },
|
||||||
|
{ name: '@elenajs/core', kb: 3.4 },
|
||||||
|
{ name: 'lit', kb: 5.3 },
|
||||||
|
{ name: 'fast-element', kb: 12.2 },
|
||||||
|
]
|
||||||
|
const max = Math.max(...data.map((d) => d.kb))
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure
|
||||||
|
class="viz-root"
|
||||||
|
aria-label="Bundle size of the same counter component per library, in kilobytes, minified and brotli-compressed. Vanilla 0.2, web-component-base 2.6, elenajs core 3.4, lit 5.3, fast-element 12.2."
|
||||||
|
>
|
||||||
|
<div class="chart">
|
||||||
|
{
|
||||||
|
data.map((d) => (
|
||||||
|
<div class={`row${d.accent ? ' is-accent' : ''}`}>
|
||||||
|
<div class="name">{d.name}</div>
|
||||||
|
<div class="track">
|
||||||
|
<div class="bar" style={`width: ${(d.kb / max) * 100}%`} />
|
||||||
|
</div>
|
||||||
|
<div class="value">{d.kb} kB</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<figcaption><a href="/comparison/">See comparison</a></figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.viz-root {
|
||||||
|
/* light */
|
||||||
|
--surface: #fcfcfb;
|
||||||
|
--text-primary: #0b0b0b;
|
||||||
|
--text-secondary: #52514e;
|
||||||
|
--muted: #898781;
|
||||||
|
--baseline: #c3c2b7;
|
||||||
|
--accent: #2a78d6;
|
||||||
|
--context: #898781;
|
||||||
|
--ring: rgba(11, 11, 11, 0.1);
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1.1rem 1.2rem 0.9rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--ring);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:where(:not([data-theme='light'])) .viz-root {
|
||||||
|
--surface: #1a1a19;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #c3c2b7;
|
||||||
|
--baseline: #383835;
|
||||||
|
--accent: #3987e5;
|
||||||
|
--ring: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme='dark'] .viz-root {
|
||||||
|
--surface: #1a1a19;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #c3c2b7;
|
||||||
|
--baseline: #383835;
|
||||||
|
--accent: #3987e5;
|
||||||
|
--ring: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* 2px surface gap between adjacent bars */
|
||||||
|
gap: 2px;
|
||||||
|
border-left: 1px solid var(--baseline);
|
||||||
|
padding-left: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 9rem) minmax(0, 1fr) 3.4rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.is-accent .name {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 13px;
|
||||||
|
min-width: 3px;
|
||||||
|
background: var(--context);
|
||||||
|
/* rounded data-end only; the baseline end stays square */
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
.is-accent .bar {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.is-accent .value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
figcaption {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
padding-left: 0.7rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
figcaption a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.2em;
|
||||||
|
}
|
||||||
|
figcaption a:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
94
docs/src/content/docs/api/cem-plugin.md
Normal file
94
docs/src/content/docs/api/cem-plugin.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
---
|
||||||
|
title: cem-plugin
|
||||||
|
slug: api/cem-plugin
|
||||||
|
description: The wcbStaticProps CEM analyzer plugin.
|
||||||
|
---
|
||||||
|
|
||||||
|
A plugin for
|
||||||
|
[`@custom-elements-manifest/analyzer`](https://custom-elements-manifest.open-wc.org/)
|
||||||
|
that teaches it to read wcb's `static props` object.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { wcbStaticProps, distPaths } from 'web-component-base/cem-plugin'
|
||||||
|
// wcbStaticProps is also available as the module's default export
|
||||||
|
import wcbStaticProps from 'web-component-base/cem-plugin'
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [CEM Analyzer Plugin guide](/cem-plugin/) for setup, Storybook and
|
||||||
|
editor integration.
|
||||||
|
|
||||||
|
## `wcbStaticProps()`
|
||||||
|
|
||||||
|
Takes no arguments and returns an analyzer plugin object.
|
||||||
|
|
||||||
|
| Field | Type | |
|
||||||
|
| -------------- | ----------------------- | ----------------------------------- |
|
||||||
|
| `name` | `string` | the plugin name |
|
||||||
|
| `analyzePhase` | `(ctx: any) => void` | the analyzer hook |
|
||||||
|
|
||||||
|
```js
|
||||||
|
// custom-elements-manifest.config.mjs
|
||||||
|
import { wcbStaticProps } from 'web-component-base/cem-plugin'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
globs: ['src/**/*.js'],
|
||||||
|
outdir: '.',
|
||||||
|
plugins: [wcbStaticProps()],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
For each class that extends `WebComponent`, it reads the `static props`
|
||||||
|
initializer and, for every key, records in the manifest:
|
||||||
|
|
||||||
|
- the **member** under its camelCase name
|
||||||
|
- the **attribute** under its kebab-case name, linked to that member
|
||||||
|
- the **type** inferred from the default value: `boolean`, `number`, `object`
|
||||||
|
or `string`
|
||||||
|
- the **default value** as written
|
||||||
|
|
||||||
|
Without it the analyzer sees `props` as one opaque static field and emits no
|
||||||
|
attributes, so editor completion and Storybook controls have nothing to read.
|
||||||
|
|
||||||
|
The `static props` initializer must resolve to an object literal in the same
|
||||||
|
source file.
|
||||||
|
|
||||||
|
## `distPaths(options?)`
|
||||||
|
|
||||||
|
A companion plugin that rewrites each module's `path` in the manifest from the
|
||||||
|
scanned source to the built output a package publishes, so a shipped
|
||||||
|
`custom-elements.json` points at files consumers can actually import.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// custom-elements-manifest.config.mjs
|
||||||
|
import { wcbStaticProps, distPaths } from 'web-component-base/cem-plugin'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
globs: ['src/**/*.ts'],
|
||||||
|
outdir: '.',
|
||||||
|
plugins: [wcbStaticProps(), distPaths()],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Type | Default | |
|
||||||
|
| --------- | ------------------------ | -------- | ---------------------------------------------- |
|
||||||
|
| `rootDir` | `string` | `'src'` | source directory prefix to replace |
|
||||||
|
| `outDir` | `string` | `'dist'` | built-output directory to point at |
|
||||||
|
| `ext` | `Record<string, string>` | see below | extension remap, merged over the defaults |
|
||||||
|
|
||||||
|
Zero-config it maps the `rootDir` prefix to `outDir` and rewrites TypeScript
|
||||||
|
extensions to their emitted JS form — `.ts` → `.js`, `.mts` → `.mjs`,
|
||||||
|
`.cts` → `.cjs`. Other extensions pass through, so a plain `.js` source only
|
||||||
|
has its directory swapped. `options.ext` merges over that default map.
|
||||||
|
|
||||||
|
References that point back at a module — `exports[].declaration`, `superclass`,
|
||||||
|
`mixins[]` — are rewritten along with the paths, so the `module` a consumer
|
||||||
|
follows still resolves to a module in the manifest. A reference carrying a
|
||||||
|
`package` names another package's layout and is left untouched.
|
||||||
|
|
||||||
|
It runs in the analyzer's `packageLinkPhase` (after `wcbStaticProps`'s
|
||||||
|
`analyzePhase`), so ordering the two in the `plugins` array does not matter.
|
||||||
|
Run `cem analyze` after your build so the files the rewritten paths point at
|
||||||
|
exist. See the [publishing note in the
|
||||||
|
guide](/cem-plugin/#ship-the-manifest-with-a-package-distpaths).
|
||||||
85
docs/src/content/docs/api/html.md
Normal file
85
docs/src/content/docs/api/html.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
---
|
||||||
|
title: html
|
||||||
|
slug: api/html
|
||||||
|
description: The html tagged template function and the vnode shape it produces.
|
||||||
|
---
|
||||||
|
|
||||||
|
A tagged template function that turns markup into a vnode tree, for use as a
|
||||||
|
component's [`template`](/api/web-component/#template).
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { html } from 'web-component-base'
|
||||||
|
// or
|
||||||
|
import { html } from 'web-component-base/html.js'
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
get template() {
|
||||||
|
return html`<p class="greeting">Hello, ${this.props.name}!</p>`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It is [htm](https://github.com/developit/htm) bound to a hyperscript factory, so
|
||||||
|
the full htm syntax applies: standard HTML, self-closing tags, `${}`
|
||||||
|
interpolation in text and attribute positions, spread props (`...${obj}`), and
|
||||||
|
optional closing tags (`<//>`).
|
||||||
|
|
||||||
|
## Return value
|
||||||
|
|
||||||
|
| Markup | Returns |
|
||||||
|
| ----------------- | ------------------------------------------- |
|
||||||
|
| a single root | one vnode object |
|
||||||
|
| several roots | an array of vnodes |
|
||||||
|
| nothing | `undefined` |
|
||||||
|
|
||||||
|
A vnode is a plain object:
|
||||||
|
|
||||||
|
```js
|
||||||
|
html`<p class="a">hi</p>`
|
||||||
|
// { type: 'p', props: { class: 'a' }, children: ['hi'] }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ---------- | ----------------- | ---------------------------------------------------- |
|
||||||
|
| `type` | `string` | the tag name |
|
||||||
|
| `props` | `object \| null` | attributes and properties as written |
|
||||||
|
| `children` | `any[]` | child vnodes and text; text stays as a raw value |
|
||||||
|
|
||||||
|
Because the tree is a plain object it is comparable and serializable, which is
|
||||||
|
what lets `render()` diff one render against the next.
|
||||||
|
|
||||||
|
`` html`` `` returns `undefined`. This is the idiomatic way for a component to
|
||||||
|
render nothing, and it empties the rendered subtree rather than leaving the
|
||||||
|
previous render on screen.
|
||||||
|
|
||||||
|
See it live: [Templating demo ↗](https://demo.webcomponent.io/examples/templating/)
|
||||||
|
|
||||||
|
## How props are applied
|
||||||
|
|
||||||
|
Each entry in `props` is applied by [`applyProp`](/api/utils/#applypropel-prop-value),
|
||||||
|
in this order:
|
||||||
|
|
||||||
|
1. a `style` object is applied rule by rule
|
||||||
|
2. a name the element owns as a **DOM property** is assigned to that property,
|
||||||
|
so event handlers (`onclick=${fn}`) and non-string values keep their type
|
||||||
|
3. a boolean value with no matching DOM property is toggled as an HTML boolean
|
||||||
|
attribute
|
||||||
|
4. anything else is serialized and set as an attribute
|
||||||
|
|
||||||
|
The same rule applies to freshly created and patched elements, so a prop behaves
|
||||||
|
identically on first render and re-render.
|
||||||
|
|
||||||
|
A `style` prop accepts an object of camelCase CSS properties:
|
||||||
|
|
||||||
|
```js
|
||||||
|
html`<div style=${{ color: 'red', padding: '1em' }}>x</div>`
|
||||||
|
```
|
||||||
|
|
||||||
|
See it live: [Style objects demo ↗](https://demo.webcomponent.io/examples/style-objects/)
|
||||||
|
|
||||||
|
## Re-rendering
|
||||||
|
|
||||||
|
Returning a vnode tree opts into in-place reconciliation: elements of the same
|
||||||
|
tag are reused, only changed props and text are touched, and leftover nodes are
|
||||||
|
trimmed. See [Template vs Render](/template-vs-render/) for what that preserves
|
||||||
|
and the non-keyed matching caveat. See it live: [Render reconciliation demo ↗](https://demo.webcomponent.io/examples/render-reconciliation/)
|
||||||
136
docs/src/content/docs/api/utils.md
Normal file
136
docs/src/content/docs/api/utils.md
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
---
|
||||||
|
title: Utilities
|
||||||
|
slug: api/utils
|
||||||
|
description: Case conversion, attribute serialization, element creation and the vnode reconciler.
|
||||||
|
---
|
||||||
|
|
||||||
|
The helpers `WebComponent` uses internally, exported so you can use them
|
||||||
|
directly. Import from the `utils` entry point or each module separately:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { serialize, getKebabCase } from 'web-component-base/utils'
|
||||||
|
// or
|
||||||
|
import { serialize } from 'web-component-base/utils/serialize.js'
|
||||||
|
```
|
||||||
|
|
||||||
|
See it live: [Just the parts demo ↗](https://demo.webcomponent.io/examples/just-parts/) builds a component from these helpers without extending the base class.
|
||||||
|
|
||||||
|
## Case conversion
|
||||||
|
|
||||||
|
### `getCamelCase(kebab)`
|
||||||
|
|
||||||
|
Converts a kebab-case attribute name to its camelCase prop key.
|
||||||
|
|
||||||
|
| Parameter | Type | |
|
||||||
|
| ----------- | -------- | ------------------------ |
|
||||||
|
| `kebab` | `string` | the attribute name |
|
||||||
|
| **returns** | `string` | the prop key |
|
||||||
|
|
||||||
|
```js
|
||||||
|
getCamelCase('max-count') // 'maxCount'
|
||||||
|
```
|
||||||
|
|
||||||
|
### `getKebabCase(str)`
|
||||||
|
|
||||||
|
Converts a camelCase prop key to its kebab-case attribute name. This is the
|
||||||
|
mapping `observedAttributes` uses.
|
||||||
|
|
||||||
|
| Parameter | Type | |
|
||||||
|
| ----------- | -------- | ------------------------ |
|
||||||
|
| `str` | `string` | the prop key |
|
||||||
|
| **returns** | `string` | the attribute name |
|
||||||
|
|
||||||
|
```js
|
||||||
|
getKebabCase('maxCount') // 'max-count'
|
||||||
|
```
|
||||||
|
|
||||||
|
Consecutive capitals are treated as one word, so `parseHTML` becomes
|
||||||
|
`parse-html`.
|
||||||
|
|
||||||
|
## Attribute serialization
|
||||||
|
|
||||||
|
### `serialize(value)`
|
||||||
|
|
||||||
|
Converts a value to its attribute string form.
|
||||||
|
|
||||||
|
| Parameter | Type | |
|
||||||
|
| ----------- | -------- | -------------------------------------- |
|
||||||
|
| `value` | `any` | the value to serialize |
|
||||||
|
| **returns** | `string` | the attribute value |
|
||||||
|
|
||||||
|
Numbers, booleans and objects go through `JSON.stringify`; strings and
|
||||||
|
everything else pass through unchanged.
|
||||||
|
|
||||||
|
### `deserialize(value, type)`
|
||||||
|
|
||||||
|
Parses an attribute string back into a value of the given declared type, the
|
||||||
|
inverse of `serialize()`.
|
||||||
|
|
||||||
|
| Parameter | Type | |
|
||||||
|
| ----------- | -------- | --------------------------------------------------- |
|
||||||
|
| `value` | `string` | the attribute value |
|
||||||
|
| `type` | `string` | `'boolean'`, `'number'`, `'object'`, `'undefined'` or `'string'` |
|
||||||
|
| **returns** | `any` | the parsed value |
|
||||||
|
|
||||||
|
`'boolean'` always returns `true`: strict HTML boolean-attribute semantics,
|
||||||
|
where any present value is true. Absence is handled by the caller and never
|
||||||
|
reaches here. `'number'`, `'object'` and `'undefined'` use `JSON.parse` and
|
||||||
|
throw on malformed input; strings pass through.
|
||||||
|
|
||||||
|
## Elements
|
||||||
|
|
||||||
|
### `createElement(tree)`
|
||||||
|
|
||||||
|
Builds real DOM from a vnode tree.
|
||||||
|
|
||||||
|
| Parameter | Type | |
|
||||||
|
| ----------- | -------- | ---------------------------------------------------- |
|
||||||
|
| `tree` | `any` | a vnode, an array of vnodes, or a text value |
|
||||||
|
| **returns** | `Node` | an element, a `DocumentFragment`, or a text node |
|
||||||
|
|
||||||
|
An array becomes a `DocumentFragment`; a value with no `type` becomes a text
|
||||||
|
node. Props are applied with `applyProp()` and children are created
|
||||||
|
recursively.
|
||||||
|
|
||||||
|
### `applyProp(el, prop, value)`
|
||||||
|
|
||||||
|
Applies a single vnode prop to an element, using the rule described in
|
||||||
|
[html](/api/html/#how-props-are-applied).
|
||||||
|
|
||||||
|
| Parameter | Type | |
|
||||||
|
| --------- | --------- | ---------------------------------- |
|
||||||
|
| `el` | `Element` | the element to apply the prop to |
|
||||||
|
| `prop` | `string` | the prop name as written in the vnode |
|
||||||
|
| `value` | `any` | the prop value |
|
||||||
|
|
||||||
|
Shared with the reconciler, so a patched element gets props by exactly the same
|
||||||
|
rule as a freshly created one.
|
||||||
|
|
||||||
|
## Reconciler
|
||||||
|
|
||||||
|
These power the in-place re-render described in
|
||||||
|
[Template vs Render](/template-vs-render/). Matching is **index-based and
|
||||||
|
non-keyed**.
|
||||||
|
|
||||||
|
### `patchChildren(parent, oldChildren, newChildren)`
|
||||||
|
|
||||||
|
Reconciles a parent node's children from one vnode list to another, patching
|
||||||
|
matches in place and trimming leftovers.
|
||||||
|
|
||||||
|
| Parameter | Type | |
|
||||||
|
| ------------- | ------ | ---------------------------------------------- |
|
||||||
|
| `parent` | `Node` | the parent node to patch into |
|
||||||
|
| `oldChildren` | `any` | the previous vnode children, or previous tree |
|
||||||
|
| `newChildren` | `any` | the new vnode children, or new tree |
|
||||||
|
|
||||||
|
### `patchNode(parent, dom, oldVnode, newVnode)`
|
||||||
|
|
||||||
|
Reconciles a single node position. Reuses `dom` when the vnode type matches,
|
||||||
|
otherwise replaces it.
|
||||||
|
|
||||||
|
| Parameter | Type | |
|
||||||
|
| ---------- | ----------------- | ---------------------------------------- |
|
||||||
|
| `parent` | `Node` | the parent node being patched |
|
||||||
|
| `dom` | `Node \| null` | the existing node at this index, if any |
|
||||||
|
| `oldVnode` | `any` | the vnode that produced `dom`, if known |
|
||||||
|
| `newVnode` | `any` | the vnode to render |
|
||||||
277
docs/src/content/docs/api/web-component.md
Normal file
277
docs/src/content/docs/api/web-component.md
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
---
|
||||||
|
title: WebComponent
|
||||||
|
slug: api/web-component
|
||||||
|
description: 'The WebComponent base class: static configuration, instance members, lifecycle hooks and attribute converters.'
|
||||||
|
---
|
||||||
|
|
||||||
|
The base class every component extends. Import from the package root or its own
|
||||||
|
module:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { WebComponent } from 'web-component-base'
|
||||||
|
// or
|
||||||
|
import { WebComponent } from 'web-component-base/WebComponent.js'
|
||||||
|
```
|
||||||
|
|
||||||
|
`WebComponent` extends `HTMLElement`, so a subclass is registered with
|
||||||
|
`customElements.define()` like any other custom element.
|
||||||
|
|
||||||
|
In TypeScript, pass the shape of `static props` as a type argument to get a
|
||||||
|
typed `this.props`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CozyButtonProps = {
|
||||||
|
variant: 'primary' | 'ghost'
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
class CozyButton extends WebComponent<CozyButtonProps> {
|
||||||
|
static props: CozyButtonProps = {
|
||||||
|
variant: 'primary',
|
||||||
|
disabled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Static properties
|
||||||
|
|
||||||
|
### `static props`
|
||||||
|
|
||||||
|
An object of declared prop names and their default values.
|
||||||
|
|
||||||
|
```js
|
||||||
|
static props = { count: 0, label: 'hi', disabled: false }
|
||||||
|
```
|
||||||
|
|
||||||
|
It drives three things at once:
|
||||||
|
|
||||||
|
- **Observed attributes.** Each key is kebab-cased, so `maxCount` observes
|
||||||
|
`max-count`.
|
||||||
|
- **The runtime type guard.** The `typeof` each default becomes the prop's
|
||||||
|
declared type. A write of a different type is rejected (see
|
||||||
|
[`strictProps`](#static-strictprops)).
|
||||||
|
- **The compile-time type of `this.props`** when the object is passed as the
|
||||||
|
class type argument.
|
||||||
|
|
||||||
|
Defaults are copied per instance with `structuredClone`, so object and array
|
||||||
|
defaults are never shared between instances. Values that cannot be cloned
|
||||||
|
(functions, class instances) are kept by reference instead of throwing.
|
||||||
|
|
||||||
|
On first use of each class, defaults that cannot reflect to an attribute are
|
||||||
|
reported with `console.warn`:
|
||||||
|
|
||||||
|
| Default | Warning |
|
||||||
|
| ------------------ | --------------------------------------------------- |
|
||||||
|
| a function or symbol | not reflectable: use handlers or refs instead |
|
||||||
|
| `true` | boolean defaults should be `false`: invert the name |
|
||||||
|
|
||||||
|
A `true` boolean default is discouraged because HTML has no true-by-default
|
||||||
|
boolean attribute: absence would have to mean both "false" and "default". Name
|
||||||
|
the prop for its `false` state (`disabled`, not `enabled`).
|
||||||
|
|
||||||
|
See it live: [Props blueprint demo ↗](https://demo.webcomponent.io/examples/props-blueprint/)
|
||||||
|
|
||||||
|
### `static styles`
|
||||||
|
|
||||||
|
CSS adopted into the shadow root as constructable stylesheet(s).
|
||||||
|
|
||||||
|
```js
|
||||||
|
static shadowRootInit = { mode: 'open' }
|
||||||
|
static styles = `p { color: red; }`
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepts a string, a `CSSStyleSheet`, or an array mixing both. An array is
|
||||||
|
adopted in declaration order, so a shared token sheet can come first and
|
||||||
|
per-component rules after it. Strings are compiled to a `CSSStyleSheet` once;
|
||||||
|
existing `CSSStyleSheet` instances are adopted as-is and can be shared across
|
||||||
|
components.
|
||||||
|
|
||||||
|
Adoption happens **once per instance**, when the element is constructed, not
|
||||||
|
per render.
|
||||||
|
|
||||||
|
Requires [`shadowRootInit`](#static-shadowrootinit). Without a shadow root
|
||||||
|
there is nothing to adopt into, and the failure is reported with
|
||||||
|
`console.error` rather than thrown.
|
||||||
|
|
||||||
|
See it live: [Constructable styles demo ↗](https://demo.webcomponent.io/examples/constructed-styles/)
|
||||||
|
|
||||||
|
### `static shadowRootInit`
|
||||||
|
|
||||||
|
A [`ShadowRootInit`](https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#options)
|
||||||
|
object. Its presence is what opts the component into shadow DOM. The shadow
|
||||||
|
root is attached during construction and becomes the render target.
|
||||||
|
|
||||||
|
```js
|
||||||
|
static shadowRootInit = { mode: 'open' }
|
||||||
|
```
|
||||||
|
|
||||||
|
Without it the component renders into its own light DOM.
|
||||||
|
|
||||||
|
See it live: [Shadow DOM demo ↗](https://demo.webcomponent.io/examples/use-shadow/)
|
||||||
|
|
||||||
|
### `static strictProps`
|
||||||
|
|
||||||
|
When `true`, assigning a value whose type does not match the declared type
|
||||||
|
throws a `TypeError`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
static strictProps = true
|
||||||
|
```
|
||||||
|
|
||||||
|
The default is to report the violation with `console.error` and skip the write,
|
||||||
|
so a stray assignment cannot halt `render()` or `onChanges()`.
|
||||||
|
|
||||||
|
Either way, `null` and `undefined` are always allowed.
|
||||||
|
|
||||||
|
See it live: [Prop type enforcement demo ↗](https://demo.webcomponent.io/examples/strict-props/)
|
||||||
|
|
||||||
|
### `static get observedAttributes`
|
||||||
|
|
||||||
|
Returns the kebab-cased keys of [`static props`](#static-props). Provided by the
|
||||||
|
base class; you do not normally define it yourself.
|
||||||
|
|
||||||
|
## Instance properties
|
||||||
|
|
||||||
|
### `props`
|
||||||
|
|
||||||
|
Read-only accessor returning a `Proxy` over the component's prop values. Read
|
||||||
|
and write camelCase keys directly:
|
||||||
|
|
||||||
|
```js
|
||||||
|
this.props.count += 1
|
||||||
|
```
|
||||||
|
|
||||||
|
A write that changes the value reflects to the matching attribute through
|
||||||
|
[`toAttribute()`](#toattributename-value), which in turn triggers a render.
|
||||||
|
Assigning the value it already holds does nothing.
|
||||||
|
|
||||||
|
### `template`
|
||||||
|
|
||||||
|
Read-only getter returning what the component renders. Two kinds are supported:
|
||||||
|
|
||||||
|
- an [`html`](/api/html/) tagged template: a vnode tree, reconciled in place
|
||||||
|
on re-render
|
||||||
|
- a **string**: assigned to the render target's `innerHTML`
|
||||||
|
|
||||||
|
Both render into the same target: the shadow root when `shadowRootInit` is set,
|
||||||
|
the element itself otherwise. Returning `` html`` `` (which is `undefined`) or
|
||||||
|
`''` empties the rendered subtree, which is how a component renders nothing
|
||||||
|
without disturbing light-DOM children a consumer slotted in.
|
||||||
|
|
||||||
|
Switching between the two kinds is safe in either direction: a string render
|
||||||
|
resets the vnode bookkeeping so the next vnode render rebuilds from scratch.
|
||||||
|
|
||||||
|
The base implementation returns `''`.
|
||||||
|
|
||||||
|
See it live: [Templating demo ↗](https://demo.webcomponent.io/examples/templating/)
|
||||||
|
|
||||||
|
### `render()`
|
||||||
|
|
||||||
|
Renders `template` into the render target. Called automatically on connect and
|
||||||
|
on every prop or attribute change; you rarely call it yourself.
|
||||||
|
|
||||||
|
For a vnode template, the new tree is compared against the previous one and
|
||||||
|
re-render **patches the existing DOM in place**. See
|
||||||
|
[Template vs Render](/template-vs-render/) for what that preserves and the
|
||||||
|
non-keyed matching caveat.
|
||||||
|
|
||||||
|
## Lifecycle hooks
|
||||||
|
|
||||||
|
Override any of these; all are no-ops by default.
|
||||||
|
|
||||||
|
| Hook | When it runs |
|
||||||
|
| ----------------- | ----------------------------------------------------- |
|
||||||
|
| `onInit()` | on connect, before the first render |
|
||||||
|
| `afterViewInit()` | on connect, after the first render |
|
||||||
|
| `onChanges(changes)` | after an observed attribute changes |
|
||||||
|
| `onDestroy()` | when the element is disconnected |
|
||||||
|
|
||||||
|
On connect the order is always: default reflection → `onInit()` → `render()` →
|
||||||
|
`afterViewInit()`. Attribute-driven renders and `onChanges()` calls that the
|
||||||
|
platform fires *before* connect are buffered, so `onInit()` is guaranteed to run
|
||||||
|
before the first render even for attributes written in markup.
|
||||||
|
|
||||||
|
`onChanges()` receives:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --------------- | -------- | -------------------------------------------- |
|
||||||
|
| `property` | `string` | camelCase prop key, matching `props` access |
|
||||||
|
| `attribute` | `string` | kebab-case attribute name that changed |
|
||||||
|
| `previousValue` | `any` | value before the change |
|
||||||
|
| `currentValue` | `any` | value after the change |
|
||||||
|
|
||||||
|
See [Life-cycle Hooks](/life-cycle-hooks/) for worked examples. See it live: [Lifecycle order demo ↗](https://demo.webcomponent.io/examples/lifecycle-order/) and [onChanges payload demo ↗](https://demo.webcomponent.io/examples/on-changes/)
|
||||||
|
|
||||||
|
## Attribute converters
|
||||||
|
|
||||||
|
Override these to control how one prop crosses the prop/attribute boundary, and
|
||||||
|
call `super` for the props you do not handle.
|
||||||
|
|
||||||
|
The default conversion round-trips values through JSON. Types JSON cannot
|
||||||
|
restore (`Date`, `Map`, `Set`, `URL`, class instances) need overridden
|
||||||
|
converters to live on `static props`; see
|
||||||
|
[Custom attribute conversion](/prop-access/#custom-attribute-conversion) for
|
||||||
|
worked examples, including the non-serializable cases.
|
||||||
|
|
||||||
|
### `toAttribute(name, value)`
|
||||||
|
|
||||||
|
Converts a prop value into the attribute value that reflects it.
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | -------- | ---------------------------------------- |
|
||||||
|
| `name` | `string` | camelCase prop key |
|
||||||
|
| `value` | `any` | the prop value being reflected |
|
||||||
|
| **returns** | `string \| null` | the attribute value, or `null` to remove the attribute |
|
||||||
|
|
||||||
|
Returning `null` **removes** the attribute. That is how a `false` boolean
|
||||||
|
becomes an absent attribute, and it works for any prop.
|
||||||
|
|
||||||
|
```js
|
||||||
|
toAttribute(name, value) {
|
||||||
|
if (name === 'point') return `${value.x},${value.y}`
|
||||||
|
return super.toAttribute(name, value)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `fromAttribute(name, value)`
|
||||||
|
|
||||||
|
Converts an attribute value into the prop value it represents, the inverse of
|
||||||
|
`toAttribute()`.
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --------- | -------- | --------------------------------------------- |
|
||||||
|
| `name` | `string` | camelCase prop key |
|
||||||
|
| `value` | `string` | the attribute value, never `null` |
|
||||||
|
| **returns** | `any` | the value to store on `this.props[name]` |
|
||||||
|
|
||||||
|
Only called for attributes that are **present**. Removal is handled by the
|
||||||
|
declared-default reset instead, so a converter never has to handle `null`.
|
||||||
|
|
||||||
|
A malformed value for a typed prop falls back to the raw string rather than
|
||||||
|
throwing, so `render()` and `onChanges()` are never skipped.
|
||||||
|
|
||||||
|
See it live: [Custom attribute converters demo ↗](https://demo.webcomponent.io/examples/attribute-converters/) and [Typed props demo ↗](https://demo.webcomponent.io/examples/type-restore/)
|
||||||
|
|
||||||
|
## Boolean props
|
||||||
|
|
||||||
|
Boolean props follow the HTML convention in both directions: **presence means
|
||||||
|
`true`, absence means `false`**.
|
||||||
|
|
||||||
|
| State | Attribute | `toAttribute` returns |
|
||||||
|
| ------- | -------------------- | --------------------- |
|
||||||
|
| `true` | present, empty value | `''` |
|
||||||
|
| `false` | absent | `null` |
|
||||||
|
|
||||||
|
Any present value reads as `true`, including the literal `flag="false"`, just
|
||||||
|
as native `disabled="false"` is still disabled. Removing the attribute always
|
||||||
|
yields `false`, never the declared default.
|
||||||
|
|
||||||
|
Use `toggleAttribute(name, bool)` to set them. Writing
|
||||||
|
`setAttribute(name, String(bool))` always means `true`; wcb warns in the console
|
||||||
|
when it sees a boolean attribute written as `"true"` or `"false"` so the
|
||||||
|
inversion cannot fail silently.
|
||||||
|
|
||||||
|
Attributes whose `"false"` is meaningful (`aria-*`, `contenteditable`) should
|
||||||
|
be declared as **string** props.
|
||||||
|
|
||||||
|
See it live: [Boolean props demo ↗](https://demo.webcomponent.io/examples/boolean-props/)
|
||||||
280
docs/src/content/docs/guides/cem-plugin.mdx
Normal file
280
docs/src/content/docs/guides/cem-plugin.mdx
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
---
|
||||||
|
title: CEM Analyzer Plugin
|
||||||
|
slug: cem-plugin
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Aside } from '@astrojs/starlight/components'
|
||||||
|
|
||||||
|
A CEM (`custom-elements.json`) is a standard description of the elements a
|
||||||
|
package defines (their tags, attributes, properties, events and slots) so
|
||||||
|
tooling can read your components without executing them. Read more at
|
||||||
|
[custom-elements-manifest.open-wc.org](https://custom-elements-manifest.open-wc.org/)
|
||||||
|
for the analyzer and its plugin API, or the [schema and
|
||||||
|
specification](https://github.com/webcomponents/custom-elements-manifest) for
|
||||||
|
the file format itself.
|
||||||
|
|
||||||
|
The CEM Analyzer (`@custom-elements-manifest/analyzer`), by default, will read
|
||||||
|
`static props` as one static field and emits no attributes for it. This causes
|
||||||
|
tooling like Storybook and code editors autocomplete to have nothing to read.
|
||||||
|
|
||||||
|
Using our `web-component-base/cem-plugin`, the CEM Analyzer will expand each
|
||||||
|
prop into a typed manifest attribute and matching public field. The steps below
|
||||||
|
will guide you through installing the CEM Analyzer and configuring it so that
|
||||||
|
tooling can work with your `wcb` custom elements.
|
||||||
|
|
||||||
|
<Aside type="tip" title="Starting a new project?">
|
||||||
|
`npm create wcb@latest` scaffolds a project with the install and
|
||||||
|
configuration below already done: a starter component, this plugin and
|
||||||
|
[`distPaths()`](#ship-the-manifest-with-a-package-distpaths) configured in
|
||||||
|
`custom-elements-manifest.config.mjs`, an `analyze` script, and the
|
||||||
|
`customElements` field set in `package.json`. Use this guide to add the
|
||||||
|
manifest to an existing project, to understand what the scaffold configured,
|
||||||
|
or to wire the manifest into [Storybook](#storybook) or [your
|
||||||
|
editor](#code-editors) — which the scaffold leaves to you.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm i -D @custom-elements-manifest/analyzer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configure
|
||||||
|
|
||||||
|
```js
|
||||||
|
// custom-elements-manifest.config.mjs
|
||||||
|
import { wcbStaticProps } from 'web-component-base/cem-plugin'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
globs: ['src/**/*.{js,ts}'],
|
||||||
|
outdir: '.',
|
||||||
|
plugins: [wcbStaticProps()],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the analyzer:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx cem analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="caution" title="`cem analyze` fails by hanging, not by erroring">
|
||||||
|
Run it **from the directory holding your config and a `package.json`**. Two
|
||||||
|
things make it appear to do nothing, neither of which prints an error:
|
||||||
|
|
||||||
|
- **No config found**: it falls back to a default glob of
|
||||||
|
`**/*.{js,ts,tsx}`, which does _not_ exclude `node_modules`. In a real
|
||||||
|
project that is tens of thousands of files through the TypeScript parser.
|
||||||
|
- **No `package.json` in the directory**: it hangs outright.
|
||||||
|
|
||||||
|
With globs scoped to your source it should finish in well under a second.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## What it produces
|
||||||
|
|
||||||
|
Given a component:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/cozy-button.ts
|
||||||
|
import { WebComponent, html } from 'web-component-base'
|
||||||
|
|
||||||
|
type CozyButtonProps = {
|
||||||
|
variant: 'primary' | 'ghost'
|
||||||
|
disabled: boolean
|
||||||
|
maxCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CozyButton extends WebComponent<CozyButtonProps> {
|
||||||
|
static props: CozyButtonProps = {
|
||||||
|
variant: 'primary',
|
||||||
|
disabled: false,
|
||||||
|
maxCount: 3,
|
||||||
|
}
|
||||||
|
static shadowRootInit = { mode: 'open' }
|
||||||
|
static styles = ':host { display: inline-block }'
|
||||||
|
get template() {
|
||||||
|
return html`<button>${this.props.variant}</button>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('cozy-button', CozyButton)
|
||||||
|
```
|
||||||
|
|
||||||
|
`custom-elements.json` gains a typed attribute and a matching public field per prop:
|
||||||
|
|
||||||
|
| attribute | type | field | default |
|
||||||
|
| ----------- | --------- | ---------- | ----------- |
|
||||||
|
| `variant` | `string` | `variant` | `'primary'` |
|
||||||
|
| `disabled` | `boolean` | `disabled` | `false` |
|
||||||
|
| `max-count` | `number` | `maxCount` | `3` |
|
||||||
|
|
||||||
|
...and `props`, `shadowRootInit`, `styles`, `strictProps`, `observedAttributes` and `template` are stripped from the public surface.
|
||||||
|
|
||||||
|
Two details worth knowing:
|
||||||
|
|
||||||
|
- **Types come from the default literal**: `true`/`false` → `boolean`, numeric → `number`, object/array → `object`, everything else → `string`. The TypeScript annotation is not consulted, so `variant`'s union still lands in the manifest as `string`.
|
||||||
|
- **Attribute names come from wcb's own `getKebabCase`**, the same function `observedAttributes` uses, so manifest names can't drift from what the component actually observes.
|
||||||
|
|
||||||
|
## Storybook
|
||||||
|
|
||||||
|
Storybook's web-components renderer builds **autodocs and controls** from a [Custom Elements Manifest](https://github.com/webcomponents/custom-elements-manifest).
|
||||||
|
|
||||||
|
### Wire it into Storybook
|
||||||
|
|
||||||
|
```js
|
||||||
|
// .storybook/preview.js
|
||||||
|
import { setCustomElementsManifest } from '@storybook/web-components-vite'
|
||||||
|
import manifest from '../custom-elements.json'
|
||||||
|
|
||||||
|
setCustomElementsManifest(manifest)
|
||||||
|
|
||||||
|
export default { tags: ['autodocs'] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Bind a story to the tag name and Storybook infers the rest, giving a text field for `variant`, a toggle for `disabled`, a number input for `maxCount`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// cozy-button.stories.js
|
||||||
|
import { html } from 'lit'
|
||||||
|
import '../src/cozy-button.ts'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Cozy/Button',
|
||||||
|
component: 'cozy-button', // ← no argTypes needed
|
||||||
|
render: ({ variant, disabled, maxCount }) => html`
|
||||||
|
<cozy-button
|
||||||
|
variant=${variant}
|
||||||
|
?disabled=${disabled}
|
||||||
|
max-count=${maxCount}
|
||||||
|
></cozy-button>
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default = {
|
||||||
|
args: { variant: 'primary', disabled: false, maxCount: 3 },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="note" title="The `html` in story files is lit's, not wcb's">
|
||||||
|
Storybook's web-components renderer renders stories with
|
||||||
|
[lit-html](https://lit.dev/docs/templates/overview/), so story files import
|
||||||
|
`html` from `lit` and templates in them use **lit's binding syntax** — the
|
||||||
|
`?disabled=${disabled}` prefix above is lit for "add or remove the boolean
|
||||||
|
attribute" (a plain `disabled=${false}` would write `disabled="false"`,
|
||||||
|
which wcb [parses as `true`](/prop-access/#boolean-props)). None of this
|
||||||
|
applies to your component: it keeps wcb's own `html` tag with no special
|
||||||
|
binding prefixes, and lit is a devDependency of the Storybook setup, never
|
||||||
|
shipped with the component.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
<Aside type="tip">
|
||||||
|
Regenerate the manifest before starting Storybook (`cem analyze && storybook
|
||||||
|
dev`). `custom-elements.json` is a build artifact, so it is usually
|
||||||
|
gitignored and rebuilt on demand.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
For a complete working setup, see [`storybook/`](https://github.com/ayo-run/wcb/tree/main/storybook) in the wcb repo, which runs this configuration against the demo components.
|
||||||
|
|
||||||
|
## Code editors
|
||||||
|
|
||||||
|
Once `custom-elements.json` exists, editors can offer tag-name and attribute autocomplete for your components, driven by the same `static props` the plugin reads, so the hints can't drift from the code.
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
VS Code does **not** read `custom-elements.json` natively. Nothing happens
|
||||||
|
just because the file exists. You need one of the two routes below.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
First, declare the manifest file in your `package.json`. Route 2's language server
|
||||||
|
discovers it through this field, and it's the ecosystem convention other manifest-driven
|
||||||
|
tools will look for:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customElements": "custom-elements.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route 1: native VS Code, no extension
|
||||||
|
|
||||||
|
VS Code's built-in HTML language service reads its own [custom data](https://github.com/microsoft/vscode-custom-data) format. A second analyzer plugin converts the manifest into it, so both files come out of one `cem analyze` run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm i -D cem-plugin-vs-code-custom-data-generator
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// custom-elements-manifest.config.mjs
|
||||||
|
import { wcbStaticProps } from 'web-component-base/cem-plugin'
|
||||||
|
import { generateCustomData } from 'cem-plugin-vs-code-custom-data-generator'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
globs: ['src/**/*.{js,ts}'],
|
||||||
|
outdir: '.',
|
||||||
|
plugins: [wcbStaticProps(), generateCustomData()],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// .vscode/settings.json
|
||||||
|
{
|
||||||
|
"html.customData": ["./vscode.html-custom-data.json"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
`html.customData` paths resolve from the **workspace root**, not from the
|
||||||
|
settings file. If you run `cem analyze` in a subfolder, either point at
|
||||||
|
`./that-folder/vscode.html-custom-data.json` or give the generator its own
|
||||||
|
`outdir`, `generateCustomData({ outdir: '..' })`, so the file lands where
|
||||||
|
the setting expects it.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
Restart VS Code and `<cozy-` completes in HTML files, with `variant` / `disabled` / `max-count` offered as attributes and their types and defaults on hover.
|
||||||
|
|
||||||
|
**The catch:** `html.customData` only applies to `.html` files. wcb components author their markup in `html` tagged templates inside `.js` / `.ts`, and those do not go through the HTML language service. This route helps whoever writes plain HTML pages against your components. It will not light up inside your own templates.
|
||||||
|
|
||||||
|
### Route 2: a language server extension, for tagged templates
|
||||||
|
|
||||||
|
To get the same completions **inside** tagged templates, you need an extension that understands them. The most current option is the [Custom Elements Manifest Language Server](https://marketplace.visualstudio.com/items?itemName=pwrs.cem-language-server-vscode) (`pwrs.cem-language-server-vscode`). It autocompletes tag names and attributes inside template literals in both JS and TS, adds hover documentation for attributes and defaults, and discovers the manifest through the `customElements` field above, no `.vscode/settings.json` needed.
|
||||||
|
|
||||||
|
Two alternatives, both worth knowing the state of:
|
||||||
|
|
||||||
|
- [`wc-toolkit/wc-language-server`](https://wc-toolkit.com/integrations/web-components-language-server/): VS Code and JetBrains, also manifest-driven. Self-described as **alpha and experimental**.
|
||||||
|
- `Matsuuu.custom-elements-language-server-project`: the one you'll find most often in older write-ups. It is alpha, and **its repository was archived in January 2026**, so prefer one of the two above.
|
||||||
|
|
||||||
|
<Aside type="note">
|
||||||
|
This corner of the ecosystem moves quickly and every option is pre-1.0. Route
|
||||||
|
1 is the conservative choice (it is plain VS Code configuration with no
|
||||||
|
extension to go stale) at the cost of not covering tagged templates.
|
||||||
|
Ecosystem state last checked July 2026.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Ship the manifest with a package: `distPaths()`
|
||||||
|
|
||||||
|
Publishing your components to npm? The analyzer stamps each module's `path` with the exact file it scanned —
|
||||||
|
so with `globs: ['src/**/*.ts']` every `path` in `custom-elements.json` is a
|
||||||
|
`.ts` source file. Most packages publish only their built `dist/` output
|
||||||
|
(and can't `import` a `.ts` at runtime anyway), so a consumer reading that
|
||||||
|
manifest resolves paths that aren't in the tarball.
|
||||||
|
|
||||||
|
Add `distPaths()` after `wcbStaticProps()` to rewrite those paths to the
|
||||||
|
built output. Zero-config it maps `src/` → `dist/` and TypeScript extensions
|
||||||
|
to their emitted JS form (`.ts` → `.js`, `.mts` → `.mjs`, `.cts` → `.cjs`).
|
||||||
|
Other extensions pass through, so a `.js` source only has its directory
|
||||||
|
swapped:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// custom-elements-manifest.config.mjs
|
||||||
|
import { wcbStaticProps, distPaths } from 'web-component-base/cem-plugin'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
globs: ['src/**/*.ts'],
|
||||||
|
outdir: '.',
|
||||||
|
plugins: [wcbStaticProps(), distPaths()],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For a different layout, override `rootDir` / `outDir`: `distPaths({ rootDir: 'lib', outDir: 'dist/esm' })`. Run
|
||||||
|
`cem analyze` **after** your build so the `dist/` files it points at exist. See
|
||||||
|
the [API reference](/api/cem-plugin/#distpathsoptions) for the `ext` extension
|
||||||
|
remap. Not publishing the manifest — e.g. a local Storybook or editor setup
|
||||||
|
over your own source — needs none of this.
|
||||||
54
docs/src/content/docs/guides/comparison.md
Normal file
54
docs/src/content/docs/guides/comparison.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
---
|
||||||
|
title: 'WCB & Similar Libraries'
|
||||||
|
slug: comparison
|
||||||
|
---
|
||||||
|
|
||||||
|
The releases since v5 gave the `WebComponent` base class a stricter compliance with custom elements specifications, quality of life improvements, and overall robustness by combining JS components authoring expectations and stable HTML behaviors. We now have in-place re-rendering, HTML boolean semantics and overrideable attribute converters, among other improvements.
|
||||||
|
|
||||||
|
This page puts these benefits and their cost in context: how much does WCB weigh in size compared to similar web-component libraries, what does each library buy you over writing custom elements from scratch, and when is WCB the right choice.
|
||||||
|
|
||||||
|
## Measured size: the same component in each library
|
||||||
|
|
||||||
|
Numbers below are **measured** from the same minimal counter component (one reactive `count` prop, a click handler, a re-render on change) written in each library, bundled with `esbuild --bundle --minify --format=esm`, and compressed with gzip (level 9) and brotli (quality 11). This is the real "cost of your first component": library runtime + component code, everything the browser downloads.
|
||||||
|
|
||||||
|
See it live: [Library comparison demo ↗](https://demo.webcomponent.io/examples/library-comparison/) — every counter running side by side, the source of each, and the `measure.mjs` script that produces the table below.
|
||||||
|
|
||||||
|
| Library | Version | Minified | Gzip | Brotli |
|
||||||
|
| ------------------------- | ------- | -------- | ------- | ---------- |
|
||||||
|
| **web-component-base** | 6.1.4 | 6.6 kB | 2.9 kB | **2.6 kB** |
|
||||||
|
| `@elenajs/core` | 1.0.0 | 9.1 kB | 3.7 kB | 3.4 kB |
|
||||||
|
| `lit` | 3.3.3 | 15.3 kB | 5.9 kB | 5.3 kB |
|
||||||
|
| `@microsoft/fast-element` | 3.0.1 | 44.8 kB | 13.6 kB | 12.2 kB |
|
||||||
|
| vanilla `HTMLElement` | - | 0.6 kB | 0.3 kB | 0.2 kB |
|
||||||
|
|
||||||
|
For scale: even after all of the v5.2–v6.1 work, the WCB counter is **~23% smaller than Elena, ~52% smaller than Lit, and ~79% smaller than FAST**.
|
||||||
|
|
||||||
|
## Feature comparison
|
||||||
|
|
||||||
|
What each library gives you beyond extending directly from `HTMLElement`, the boilerplate you no longer write by hand:
|
||||||
|
|
||||||
|
| Capability | WCB 6.1 | Lit 3.3 | Elena 1.0 | FAST 3.0 |
|
||||||
|
| -------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------- | --------------------------------------- | ------------------------------------------------ |
|
||||||
|
| Declarative templates | ✅ `html` tagged templates (htm) or plain strings | ✅ `lit-html` tagged templates | ✅ `html` tagged templates | ✅ typed templates with binding expressions |
|
||||||
|
| Reactive props ⇄ attributes | ✅ `static props`, overrideable converters | ✅ `static properties` with converters | ✅ `static props`, opt-in reflection | ✅ `@attr` / observables |
|
||||||
|
| Update strategy | In-place patch (index-based, non-keyed) | Part-based: only touched bindings update, keyed `repeat` | Batched re-renders | Fine-grained observable bindings, keyed `repeat` |
|
||||||
|
| Preserves DOM state on re-render | ✅ since v5.2 | ✅ | ✅ | ✅ |
|
||||||
|
| Update batching / scheduling | ⚠️ renders per prop write | ✅ async batched, `updateComplete` | ✅ batched, `updateComplete` | ✅ queued/batched |
|
||||||
|
| Keyed list reconciliation | ⚠️ positional | ✅ `repeat` directive | ❌ | ✅ `repeat` with recycling controls |
|
||||||
|
| Light DOM by default | ✅ (shadow DOM opt-in via `static shadowRootInit`) | ❌ shadow DOM by default | ✅ (shadow opt-in) | ❌ shadow DOM by default |
|
||||||
|
| Scoped styles | ✅ `static styles` + constructable stylesheets (needs shadow root) | ✅ shadow-scoped CSS | ✅ scoped without shadow DOM | ✅ shadow-scoped + design tokens |
|
||||||
|
| SSR / hydration story | ✅ attribute-driven state renders from any server | ✅ `@lit-labs/ssr` + hydration | ✅ server-rendered markup + hydration utilities | ⚠️ experimental SSR |
|
||||||
|
| Works with zero build tooling | ✅ import from CDN, no compiler | ✅ (buildless possible, decorators need tooling) | ✅ | ⚠️ practical with tooling |
|
||||||
|
| Editor/IDE tooling | ✅ typed props + [CEM analyzer plugin](/cem-plugin/) | ✅ extensive (analyzer, TS decorators, IDE plugins) | ✅ CEM-focused | ✅ TS-first |
|
||||||
|
| Lifecycle hooks | `onInit`, `afterViewInit`, `onChanges`, `onDestroy` | full reactive update lifecycle | `willUpdate`, `firstUpdated`, `updated` | full lifecycle + behaviors |
|
||||||
|
| Backing / ecosystem | solo maintainer, small surface | OpenJS Foundation (donated by Google 2025), large ecosystem | new (2026), solo-authored | Microsoft, powers Fluent UI |
|
||||||
|
|
||||||
|
:::note[Why 11ty WebC isn't here]
|
||||||
|
WebC is a compile-time tool: it resolves components during an Eleventy build and ships plain HTML with no client runtime. Every row above is about what a library does _in the browser at runtime_, so a side-by-side comparison would be measuring two different things. If your components are static at build time, WebC solves a different problem.
|
||||||
|
:::
|
||||||
|
|
||||||
|
For what these numbers and capabilities add up to (and when they don't) see [Why would anyone use WCB?](/why/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_WCB re-measured 2026-07-24 at v6.1.4; the other libraries measured 2026-07-19, with esbuild, Node zlib (gzip −9, brotli q11), at the pinned versions above. Methodology: identical counter component per library, bundled per library, compressed. Re-run them yourself — the counters and the [`measure.mjs`](https://demo.webcomponent.io/examples/library-comparison/) script live in the demo workspace (`demo/examples/library-comparison/`). The benchmark is trivially reproducible with the versions pinned above._
|
||||||
|
|
@ -3,6 +3,34 @@ title: Examples
|
||||||
slug: examples
|
slug: examples
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Live demo gallery
|
||||||
|
|
||||||
|
Every example below runs as a standalone page at
|
||||||
|
[demo.webcomponent.io ↗](https://demo.webcomponent.io/), a live gallery with
|
||||||
|
the source alongside each demo.
|
||||||
|
|
||||||
|
| Demo | Shows |
|
||||||
|
| ---- | ----- |
|
||||||
|
| [Boolean props ↗](https://demo.webcomponent.io/examples/boolean-props/) | presence/absence reflection, `toggleAttribute`, `[flag]` selectors |
|
||||||
|
| [Custom attribute converters ↗](https://demo.webcomponent.io/examples/attribute-converters/) | `toAttribute`/`fromAttribute` for `Date` and array props |
|
||||||
|
| [Props blueprint ↗](https://demo.webcomponent.io/examples/props-blueprint/) | `static props` as the single source of defaults and types |
|
||||||
|
| [Prop type enforcement ↗](https://demo.webcomponent.io/examples/strict-props/) | `static strictProps` and the log-not-throw default |
|
||||||
|
| [Compile-time prop types ↗](https://demo.webcomponent.io/examples/typed-props/) | typing `this.props` in TypeScript |
|
||||||
|
| [Typed props ↗](https://demo.webcomponent.io/examples/type-restore/) | attribute round-trips restoring the declared type |
|
||||||
|
| [Templating ↗](https://demo.webcomponent.io/examples/templating/) | string vs `html` tagged-template rendering |
|
||||||
|
| [Render reconciliation ↗](https://demo.webcomponent.io/examples/render-reconciliation/) | in-place patching preserving focus, caret and input state |
|
||||||
|
| [Style objects ↗](https://demo.webcomponent.io/examples/style-objects/) | calculated and conditional styles via the `style` prop |
|
||||||
|
| [Shadow DOM ↗](https://demo.webcomponent.io/examples/use-shadow/) | `static shadowRootInit` |
|
||||||
|
| [Constructable styles ↗](https://demo.webcomponent.io/examples/constructed-styles/) | `static styles`, including composing several sheets |
|
||||||
|
| [Lifecycle order ↗](https://demo.webcomponent.io/examples/lifecycle-order/) | each hook logged as it fires |
|
||||||
|
| [Attribute lifecycle ↗](https://demo.webcomponent.io/examples/attribute-lifecycle/) | how attribute changes drive the hooks |
|
||||||
|
| [onChanges payload ↗](https://demo.webcomponent.io/examples/on-changes/) | camelCase `property` vs kebab-case `attribute` |
|
||||||
|
| [Just the parts ↗](https://demo.webcomponent.io/examples/just-parts/) | using `html`/`createElement` without the base class |
|
||||||
|
| [Kitchen sink ↗](https://demo.webcomponent.io/examples/demo/) | several features together |
|
||||||
|
| [Single-file pen ↗](https://demo.webcomponent.io/examples/pens/counter-toggle.html) | counter and toggle in one HTML file |
|
||||||
|
|
||||||
|
## CodePen examples
|
||||||
|
|
||||||
### 1. To-Do App
|
### 1. To-Do App
|
||||||
|
|
||||||
A simple app that allows adding / completing tasks:
|
A simple app that allows adding / completing tasks:
|
||||||
|
|
@ -20,7 +48,7 @@ Here is an example of using a custom element in a single .html file.
|
||||||
<head>
|
<head>
|
||||||
<title>WC Base Test</title>
|
<title>WC Base Test</title>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { WebComponent } from 'https://unpkg.com/web-component-base@latest/index.js'
|
import { WebComponent } from 'https://esm.sh/web-component-base@latest'
|
||||||
|
|
||||||
class HelloWorld extends WebComponent {
|
class HelloWorld extends WebComponent {
|
||||||
static props = {
|
static props = {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ import {
|
||||||
getCamelCase,
|
getCamelCase,
|
||||||
getKebabCase,
|
getKebabCase,
|
||||||
createElement,
|
createElement,
|
||||||
|
applyProp,
|
||||||
|
patchNode,
|
||||||
|
patchChildren,
|
||||||
} from 'web-component-base/utils'
|
} from 'web-component-base/utils'
|
||||||
|
|
||||||
// or separate files
|
// or separate files
|
||||||
|
|
|
||||||
|
|
@ -3,47 +3,63 @@ title: Getting Started
|
||||||
slug: getting-started
|
slug: getting-started
|
||||||
---
|
---
|
||||||
|
|
||||||
import { Aside, Badge } from '@astrojs/starlight/components';
|
**Web Component Base (WCB)** is a zero-dependency, tiny JS base class for creating reactive [custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_Components/Using_custom_elements).
|
||||||
|
|
||||||
**Web Component Base (WCB)**
|
|
||||||
<Badge text="alpha" variant="danger" /> is a zero-dependency, tiny JS base class for creating reactive [custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_Components/Using_custom_elements) easily.
|
|
||||||
|
|
||||||
When you extend the WebComponent class for your custom element, you only have to define the template and properties. Any change in an observed property's value will automatically cause the component UI to render.
|
When you extend the WebComponent class for your custom element, you only have to define the template and properties. Any change in an observed property's value will automatically cause the component UI to render.
|
||||||
|
|
||||||
The result is a reactive UI on property changes.
|
## Quick start
|
||||||
|
|
||||||
Note that there's a trade off between productivity & lightweight-ness here, and the project aims to help in writing custom elements in the same way more mature and better maintained projects already do. Please look into popular options such as [Microsoft's FASTElement](https://fast.design/) & [Google's LitElement](https://lit.dev/) as well.
|
**Prerequisites:** [Node.js](https://nodejs.org) (current LTS), which includes npm.
|
||||||
|
|
||||||
## Project Status
|
The fastest way to try wcb is to scaffold a new component:
|
||||||
|
|
||||||
Treat it as a **stable alpha** product. Though the public APIs are stable, most examples are only useful for simple atomic use-cases due to remaining work needed on the internals.
|
```bash
|
||||||
|
npm create wcb@latest
|
||||||
<Aside type="caution" title="Important">
|
# or name it directly: npm create wcb@latest my-button
|
||||||
For building advanced interactions, there is an in-progress work on smart diffing to prevent component children being wiped on interaction.
|
|
||||||
</Aside>
|
|
||||||
|
|
||||||
<Aside type="tip">
|
|
||||||
If you have some complex needs, we recommend using the `WebComponent` base class with a more mature rendering approach like `lit-html`, and here's a demo for that: [View on CodePen](https://codepen.io/ayoayco-the-styleful/pen/ZEwNJBR?editors=1010).
|
|
||||||
</Aside>
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
The library is distributed as complete ECMAScript Modules (ESM) and published on [NPM](https://ayco.io/n/web-component-base). Please open a [GitHub issue](https://github.com/ayo-run/wcb/issues/new) or [discussion](https://github.com/ayo-run/wcb/discussions) for problems or requests regarding our distribution. You can also submit a ticket in [SourceHut](https://todo.sr.ht/~ayoayco/wcb).
|
|
||||||
|
|
||||||
### Import via CDN
|
|
||||||
|
|
||||||
It is possible to import directly using a CDN like [esm.sh](https://esm.sh/web-component-base) or [unpkg](https://unpkg.com/web-component-base) in your vanilla JS component or HTML files. In all examples in this document, we use `unpkg` but you can find on CodePen examples that `esm.sh` also works well.
|
|
||||||
|
|
||||||
Additionally, we use `@latest` in the rest of our [usage](/usage) and [examples](/examples) here for simplicity, but take note that this incurs additional resolution steps for CDNs to find the actual latest published version. You may replace the `@latest` in the URL with specific versions as shown in our CodePen examples, and this will typically be better for performance.
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { WebComponent } from 'https://unpkg.com/web-component-base@latest/index.js'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Installation via npm
|
It sets up a publishable custom element, ready to develop:
|
||||||
|
|
||||||
Usable for projects with bundlers or using import maps pointing to the specific files downloaded in `node_modules/web-component-base`.
|
- **A starter component** in TypeScript using the [`static props`](/prop-access/) convention. The class, tag, and file names are derived from your project name (`my-button` → `MyButton` / `<my-button>`), so there are no rename-me TODOs
|
||||||
|
- **A Vite setup**: a dev server with a demo page, plus a library build (`npm run build:lib`) that emits ESM + UMD bundles and `.d.ts` types, with `web-component-base` as a peerDependency
|
||||||
|
- **`custom-elements.json` generation**: the CEM analyzer configured with wcb's plugin, an `npm run analyze` script, the `customElements` field in `package.json`, and a `prepack` hook so the manifest ships inside the published package
|
||||||
|
|
||||||
|
Run the demo page to see the starter component render:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd my-button
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite prints a local URL. Open it to see `<my-button>` rendered on the demo page. Edit `src/MyButton.ts` and the page updates as you save.
|
||||||
|
|
||||||
|
See the [CEM Analyzer Plugin guide](/cem-plugin/) for more details on the manifest and how to use it with Storybook and code editors.
|
||||||
|
|
||||||
|
Prefer starting on GitHub? The [`ayo-run/web-component`](https://github.com/ayo-run/web-component) template repository serves the same purpose via "Use this template".
|
||||||
|
|
||||||
|
## Install via npm
|
||||||
|
|
||||||
|
To add wcb to an existing project instead of scaffolding a new one, install it from [npm](https://npmx.dev/package/web-component-base):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm i web-component-base
|
npm i web-component-base
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The package ships standard ECMAScript Modules (ESM), so it works with bundlers as well as import maps pointing into `node_modules/web-component-base`.
|
||||||
|
|
||||||
|
Then import the base class with the bare package name:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { WebComponent } from 'web-component-base'
|
||||||
|
```
|
||||||
|
|
||||||
|
Continue with [Usage](/usage) to define your first component.
|
||||||
|
|
||||||
|
:::tip[Prefer a CDN?]
|
||||||
|
You can also import wcb straight from a CDN in vanilla JS or HTML files. See the [CodePen examples](/examples/#codepen-examples) for working setups.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Getting help
|
||||||
|
|
||||||
|
Open a [GitHub issue](https://github.com/ayo-run/wcb/issues/new) or [discussion](https://github.com/ayo-run/wcb/discussions) for problems or requests. You can also submit a ticket on [SourceHut](https://todo.sr.ht/~ayoayco/wcb).
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ slug: 'just-parts'
|
||||||
|
|
||||||
You don't have to extend the whole base class to use some features. All internals are exposed and usable separately so you can practically build the behavior on your own classes.
|
You don't have to extend the whole base class to use some features. All internals are exposed and usable separately so you can practically build the behavior on your own classes.
|
||||||
|
|
||||||
Here's an example of using the `html` tag template on a class that extends from vanilla `HTMLElement`... also [View on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/bGzJQJg?editors=1010).
|
Here's an example of using the `html` tag template on a class that extends from vanilla `HTMLElement`... also [View on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/bGzJQJg?editors=1010), or see it live: [Just the parts demo ↗](https://demo.webcomponent.io/examples/just-parts/).
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { html } from 'https://unpkg.com/web-component-base/html'
|
import { html } from 'https://esm.sh/web-component-base@latest/html'
|
||||||
import { createElement } from 'https://unpkg.com/web-component-base/utils'
|
import { createElement } from 'https://esm.sh/web-component-base@latest/utils'
|
||||||
|
|
||||||
class MyQuote extends HTMLElement {
|
class MyQuote extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,6 @@ slug: library-size
|
||||||
|
|
||||||
All the functions and the base class in the library are minimalist by design and only contains what is needed for their purpose.
|
All the functions and the base class in the library are minimalist by design and only contains what is needed for their purpose.
|
||||||
|
|
||||||
The main export (with `WebComponent` + `html`) is **1.7 kB** (min + gzip) according to [bundlephobia.com](https://bundlephobia.com/package/web-component-base@latest), and the `WebComponent` base class is **1.35 kB** (min + brotli) according to [size-limit](http://github.com/ai/size-limit).
|
The `WebComponent` base class is **1.98 kB** (min + brotli) according to [size-limit](http://github.com/ai/size-limit).
|
||||||
|
|
||||||
|
Every change that moves this number is recorded in [`size-change-log.md`](https://github.com/ayo-run/wcb/blob/main/size-change-log.md), with the reason the bytes were spent and the benefit they bought.
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,15 @@ slug: life-cycle-hooks
|
||||||
|
|
||||||
Define behavior when certain events in the component's life cycle is triggered by providing hook methods
|
Define behavior when certain events in the component's life cycle is triggered by providing hook methods
|
||||||
|
|
||||||
|
See it live: [Lifecycle order demo ↗](https://demo.webcomponent.io/examples/lifecycle-order/) logs each hook as it fires, and [Attribute lifecycle demo ↗](https://demo.webcomponent.io/examples/attribute-lifecycle/) shows how attribute changes drive them.
|
||||||
|
|
||||||
### onInit()
|
### onInit()
|
||||||
|
|
||||||
- Triggered when the component is connected to the DOM
|
- Triggered when the component is connected to the DOM
|
||||||
- Best for setting up the component
|
- Best for setting up the component
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { WebComponent } from 'https://unpkg.com/web-component-base@latest/index.js'
|
import { WebComponent } from 'https://esm.sh/web-component-base@latest'
|
||||||
|
|
||||||
class ClickableText extends WebComponent {
|
class ClickableText extends WebComponent {
|
||||||
// gets called when the component is used in an HTML document
|
// gets called when the component is used in an HTML document
|
||||||
|
|
@ -49,7 +51,7 @@ class ClickableText extends WebComponent {
|
||||||
- best for undoing any setup done in `onInit()`
|
- best for undoing any setup done in `onInit()`
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { WebComponent } from 'https://unpkg.com/web-component-base@latest/index.js'
|
import { WebComponent } from 'https://esm.sh/web-component-base@latest'
|
||||||
|
|
||||||
class ClickableText extends WebComponent {
|
class ClickableText extends WebComponent {
|
||||||
clickCallback() {
|
clickCallback() {
|
||||||
|
|
@ -75,14 +77,16 @@ class ClickableText extends WebComponent {
|
||||||
|
|
||||||
- Triggered when an attribute value changed
|
- Triggered when an attribute value changed
|
||||||
- The `changes` object cleanly separates the **property** from the **attribute**:
|
- The `changes` object cleanly separates the **property** from the **attribute**:
|
||||||
- `property` — the **camelCase** prop key, matching how you access `props` (e.g. `myName`)
|
- `property`: the **camelCase** prop key, matching how you access `props` (e.g. `myName`)
|
||||||
- `attribute` — the **kebab-case** attribute name that changed (e.g. `my-name`)
|
- `attribute`: the **kebab-case** attribute name that changed (e.g. `my-name`)
|
||||||
- `previousValue` / `currentValue` — the values before and after the change
|
- `previousValue` / `currentValue`: the values before and after the change
|
||||||
|
|
||||||
Use `property` to read the value straight off `props` (`this.props[property]`); use `attribute` when you need the raw attribute name.
|
Use `property` to read the value straight off `props` (`this.props[property]`); use `attribute` when you need the raw attribute name.
|
||||||
|
|
||||||
|
See it live: [onChanges payload demo ↗](https://demo.webcomponent.io/examples/on-changes/)
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { WebComponent } from 'https://unpkg.com/web-component-base@latest/index.js'
|
import { WebComponent } from 'https://esm.sh/web-component-base@latest'
|
||||||
|
|
||||||
class ClickableText extends WebComponent {
|
class ClickableText extends WebComponent {
|
||||||
// gets called when an attribute value changes
|
// gets called when an attribute value changes
|
||||||
|
|
@ -113,7 +117,7 @@ If you previously read `changes.property` for the attribute name, switch to `cha
|
||||||
|
|
||||||
## Upgrade ordering & the buffering guarantee
|
## Upgrade ordering & the buffering guarantee
|
||||||
|
|
||||||
Per the Custom Elements spec, when an element is upgraded with attributes already present in the markup (e.g. `<my-el my-name="Zoe">`), the browser fires `attributeChangedCallback` **before** `connectedCallback`. Taken literally, that means `render()` and `onChanges()` could run before `onInit()` — so any setup you do in `onInit` (event wiring, reading external state) would not have happened yet on that first render. Test environments like happy-dom/jsdom don't reproduce this ordering, so components can pass in tests and then misbehave in a real browser.
|
Per the Custom Elements spec, when an element is upgraded with attributes already present in the markup (e.g. `<my-el my-name="Zoe">`), the browser fires `attributeChangedCallback` **before** `connectedCallback`. Taken literally, that means `render()` and `onChanges()` could run before `onInit()`, so any setup you do in `onInit` (event wiring, reading external state) would not have happened yet on that first render. Test environments like happy-dom/jsdom don't reproduce this ordering, so components can pass in tests and then misbehave in a real browser.
|
||||||
|
|
||||||
`WebComponent` removes this footgun. Attribute changes that arrive **before** the element is connected are buffered:
|
`WebComponent` removes this footgun. Attribute changes that arrive **before** the element is connected are buffered:
|
||||||
|
|
||||||
|
|
@ -122,8 +126,8 @@ Per the Custom Elements spec, when an element is upgraded with attributes alread
|
||||||
|
|
||||||
On connect, the order is always:
|
On connect, the order is always:
|
||||||
|
|
||||||
1. `onInit()` — `this.props` already reflects any authored attributes
|
1. `onInit()`: `this.props` already reflects any authored attributes
|
||||||
2. a single `render()` — reflects all buffered props in one pass
|
2. a single `render()`: reflects all buffered props in one pass
|
||||||
3. `afterViewInit()`
|
3. `afterViewInit()`
|
||||||
|
|
||||||
**`onChanges()` never fires before `onInit()`.** Pre-connect attribute changes are **not** replayed through `onChanges()` — the first `render()` already reflects them, so `onChanges()` is reserved for genuine post-connect changes. After the element is connected, attribute changes behave normally: each one triggers `render()` and `onChanges()` immediately.
|
**`onChanges()` never fires before `onInit()`.** Pre-connect attribute changes are **not** replayed through `onChanges()`. The first `render()` already reflects them, so `onChanges()` is reserved for genuine post-connect changes. After the element is connected, attribute changes behave normally: each one triggers `render()` and `onChanges()` immediately.
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue