The contract
Adding a script to a framework that spent five versions not having one deserves an explicit set of rules. These are enforced by how the code is written, not by intention:
- No element is hidden by JavaScript on load. If a script hides content and then fails to load, the content is gone. Nothing here hides anything CSS was not already hiding.
- No enhancement is the only route to a feature. The menu opens without it. The tabs switch without it. The dialog opens without it, via
:target. - Every enhancement is opt-in per element, through a data attribute. Adding the file to a page changes nothing until you mark something up.
- Nothing injects CSS. The two elements the script creates — a copy button and a sort button — are styled in the Content module. A script that writes styles is a script that fights your Content-Security-Policy.
What changed in the policy
This site's CSP moved from script-src 'none' to script-src 'self'. That is the honest cost. In exchange the script tag carries a sha384 subresource-integrity hash, so a stale or truncated deploy fails loudly instead of half-running.
Adding it
<script src="/polyfish.min.js?v=a39398cb" defer></script>
defer, not async: the DOM has to exist, and there is nothing here worth blocking the parser for. Then mark up the elements you want upgraded — everything else is untouched.
Enhancements
| Attribute | On | What it adds | Without it |
|---|---|---|---|
data-pf-menu | details.pf-menu | Closes on outside click and Escape, keeps aria-expanded in sync | Opens and closes on its summary |
data-pf-tabs | .pf-tabs | Full ARIA tablist: roles, aria-selected, roving tabindex, Home and End | Radios switch panels, arrows already work |
data-pf-dialog | Any trigger | Native showModal(), focus trapping, focus returned on close | :target dialog, no focus trap |
data-pf-copy | .pf-code | A copy button and a polite status message | Select and copy by hand |
data-pf-counter | textarea | Live character count against maxlength | The browser still enforces the limit |
data-pf-dismiss | Any button | Removes its closest alert | The alert stays |
data-pf-nosort | th | Opts one column out of sorting; data-pf-value on a cell sorts on that instead of its text | — |
data-pf-sort | table | Click-to-sort headers with aria-sort, numeric-aware | The table renders in source order |
data-pf-spy | .pf-sidenav | Marks the current section with aria-current | The sidebar is a plain list of links |
data-pf-dialog-close | Any button in a dialog | Closes the native dialog it sits inside | Use form method="dialog" or the CSS-only close link |
data-pf-drawer | .pf-drawer | Escape closes it, focus moves into the panel and back to the trigger, and the page behind it stops scrolling | Opens and closes on :target |
data-pf-theme-toggle | Any button | Cycles auto, light, dark and remembers the choice. Put data-pf-theme-label on a child to have its text updated too | The palette follows the operating system |
That table is sortable — click a header. So is the module table on the installation page. The theme button in the navigation, the copy buttons on every code block and the highlighted item in the sidebar are the same script.
The one exception
PolyFish.toast() is the only thing here with no CSS-only version, because a
toast with no script is an empty box. It is documented as requiring JavaScript rather than
quietly degrading to nothing.
PolyFish.toast("Check created");
PolyFish.toast("Could not save", { variant: "danger", duration: 0 });
The region is role="status" with aria-live="polite",
which queues the message behind whatever a screen reader is currently saying. An assertive
region interrupts mid-sentence — for a confirmation that is rude rather than urgent. Hovering
or focusing a toast cancels its removal, because a message that vanishes while being read is
worse than one that stays. duration: 0 keeps it until dismissed.
Build your attribute list
Tick the enhancements you want. Everything you leave off costs nothing — the script walks for the attributes and finds none.
<!-- the script itself, 7.9 KB -->
<script src="/polyfish.min.js?v=a39398cb" defer></script>
<!-- nothing ticked: the script loads and does nothing at all --><div class="pf-tabs" data-pf-tabs> roles, aria-selected, roving tabindex<button data-pf-dialog="confirm-id"> showModal, focus returned on close<div class="pf-code" data-pf-copy> adds a copy button and a status region<textarea maxlength="280" data-pf-counter="count-id"> live count<button data-pf-dismiss=".pf-alert"> removes its closest alert<table data-pf-sort> click-to-sort with aria-sort<aside class="pf-sidenav" data-pf-spy> marks the current section<button data-pf-theme-toggle> auto / light / dark, remembered
Checkboxes and :has(). Every line above is opt-in per element — the file changes nothing until you add one.
The API
Three properties, and only the first one matters.
// upgrade a container after injecting markup
PolyFish.init(document.querySelector('#results'));
// idempotent: elements record what they already have,
// so nothing is bound twice
PolyFish.init();
PolyFish.version; // "0.5.0"
PolyFish.reducedMotion(); // true if the user asked for less motion
Tests
The script ships with a jsdom suite of 69 assertions. Twelve of them are not about features at all — they check that the page survives the script's absence: that the tabs still have a checked radio, that no element carries a hidden attribute, and that nothing depends on a class the script adds.
Two more cover failure modes rather than success: blocked localStorage, which throws outright in private mode in some browsers, and a missing clipboard API, where the copy button is not created at all rather than rendered dead.
What is still not here
No carousel, no toast queue, no combobox, no scroll animation library. The line has not moved: this file finishes things the CSS started. A component that only exists when the script runs would break the first rule on this page.
Next: the component reference.