Skip to content

countdown, content-toggle and off-canvas - #18

Merged
ecropolis merged 4 commits into
mainfrom
feat/countdown-toggle-offcanvas
Sep 26, 2026
Merged

ecropolis merged 4 commits into
mainfrom
feat/countdown-toggle-offcanvas

Conversation

@ecropolis

@ecropolis ecropolis commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Three elements from round 3, Chip B (time and toggles), one commit each. Each has its component in src/library/<id>/, a demo, a catalogue entry and a check section in the new scripts/check-time-and-toggles.mjs, which is added to the npm run check chain.

countdown

What it does. It shows days, hours, minutes and seconds to a moment. The moment is either to, a wall-clock time in the business's timeZone (required with to), or evergreen, a number of minutes that starts on each visitor's first view.

  • The evergreen start instant is kept in localStorage under key, never in a cookie. A returning visitor sees the same deadline, so the urgency stays true.
  • There are three styles: square, circle (a ring arc per unit) and plain. Unit labels are overridable for other languages, along with ends, endsIn, endedText and locale.
  • At zero, onEnd shows endedText (the default), hides the countdown, or repeats. A repeat with to counts to the same wall-clock time the next day or week (every), across DST. An evergreen repeat starts a fresh run.
  • The wall-clock parser is announcement-bar's <anb-time> approach, copied into its own <cd-time> block. The element imports nothing.

Accessibility contract. Nothing is announced as it ticks.

  • The wrapper is role="timer", whose implicit aria-live is off, and it carries no aria-live attribute at all.
  • Its aria-label gives the absolute end, for example "Ends Oct 3, 5:00 PM CDT". Once ended, the label is endedText. The label changes only when the end itself does.
  • The digits stay readable for anyone who navigates into them. Each unit is its number plus its word. The zero-padded number is aria-hidden, and screen readers get an unpadded copy, so it reads "3 days 4 hours".
  • The unit words use small capitals rather than text-transform: uppercase, which Chrome would pass to screen readers as "DAYS".
  • Under reduced motion there is no tick animation and no ring transition.

Without JavaScript. Visitors get the static "Ends Oct 3, 5:00 PM CDT" line in a <time datetime>. An evergreen countdown reads "Ends in 30 minutes" instead.

  • The digits are rendered from the build's clock but stay hidden. They would be stale and would never tick, so showing them would mislead.
  • With JavaScript, the script right after the element shows the digits before the first paint, so there is no layout shift.

What the check pins.

  • Golden time cases, run in Node on the <cd-time> source:
    • wall-clock instants in CDT and CST;
    • the DST gap (New York 02:30 on spring-forward day resolves to the jump);
    • the second step of the zone walk, just after each DST transition;
    • half-hour zones: Kolkata, St John's and Adelaide;
    • repeats: the next daily occurrence across fall-back, rollover exactly at the end instant, a daily repeat into and past the gap, weekly repeats including one 18 months on, and no-every staying passed;
    • unit splitting: the largest unit absorbs the rest, and the smallest rounds up;
    • the evergreen start when the stored value is kept, missing, in the future, garbage or empty;
    • the end text, and date validation.
  • The built page: role="timer" with an "Ends …" label and no aria-live attribute. Each unit has an aria-hidden padded number, a matching unpadded number and its unit word. The digits are hidden without JavaScript. It also pins the end line with a parseable datetime, the mount script straight after the element, and the runtime once per page.
  • The source: motion only under prefers-reduced-motion: no-preference, localStorage and never document.cookie, and the square fallback pair at 14.75:1.
  • Mutation test: 20 mutants, all caught. They include a one-step DST walk, a UTC wall clock, >= in the repeat, weekly-as-daily, a floored smallest unit, a trusted future evergreen start, aria-live="polite", an explicit aria-live="off", role="img", aria-hidden digits, a padded number read out, a missing unpadded number, a dropped unit word, visible digits without JavaScript, a missing label, a moved mount script, a doubled runtime, an ungated animation, a cookie, and a low-contrast fallback.

Search. The page targets "countdown timer widget" (320/mo, difficulty 36). Its alsoRanks are "countdown timer html" (320/53), "sales countdown timer" (40/44) and "evergreen countdown timer" (10/13). Two queries are left unclaimed: "countdown widget" (4,400/64) is out of reach, and "email countdown timer" (480/37) is a different product. All figures are SE Ranking US, 2026-09-26.

content-toggle

What it does. It switches between two versions of one thing: monthly or annual prices, homes or businesses.

  • labels: [a, b] and two panels in slots a and b.
  • style="switch" (the default) puts the labels either side of a <button role="switch">. style="buttons" uses two <button aria-pressed> for long labels.
  • default, and an optional badge ("Save 20%") on one label via badgeOn.
  • remember keeps the choice in sessionStorage. param lets ?<param>=b, or the label text, preselect.
  • Each change dispatches a content-toggle event and sets data-ct-state.

Accessibility contract.

  • The switch is named by label b, and aria-checked means "b is shown", so it reads as "Annual, switch, on". It carries aria-controls for both panels, and the badge describes it.
  • Space and Enter toggle it. Clicking either visible label sets it.
  • In buttons style, exactly one of the two buttons is pressed, inside a named role="group".
  • The shown panel keeps its label as a heading for screen readers only. The other panel is hidden.
  • Under reduced motion the knob does not slide and the panel does not fade. In forced-colours mode the switch uses system colours.

Without JavaScript. Both panels render, each under its label as a heading. The switch, which could not work, is not shown. With JavaScript, the script right after the element settles it before the first paint.

Not tabs. A toggle is two versions of one thing, switched in place. Three or more views, or different topics, are tabs. The catalogue's notFor says so.

What the check pins.

  • The built page: the bar hidden, panels a and b both visible and headed by their labels, and exactly one type="button" role="switch" whose aria-checked matches default and which is named by label b.
  • Buttons style: a named group with exactly one button pressed.
  • Both styles: aria-controls pointing at real panels, no tab roles, the mount script straight after, and the runtime once per page.
  • The source: motion gated, sessionStorage only, pressed and badge fallbacks at 4.5:1 or more (7.27 and 8.19), and the track at 3:1 against white.
  • Mutation test: 14 mutants, all caught.

Search. The page targets "toggle content" (110/mo, difficulty 27). Its alsoRanks are "content switcher" (90/18), "css toggle switch" (170/16) and "html toggle switch" (90/9). "content toggle" and "pricing toggle" have no US volume, and "toggle switch" (22,400/64) is mostly hardware.

off-canvas

What it does. It is a native <dialog> that slides in from side (left, right, top or bottom). Any element with data-offcanvas-open="<id>" opens it.

  • mode="modal" (the default) uses showModal(): a backdrop, Escape to close, and a scroll lock that keeps the scrollbar width.
  • mode="push" uses show(): non-modal, with no backdrop and no lock. While open it publishes --ofc-push (the panel's size) and data-ofc-push (its side) on <html>. The host moves its page with html[data-ofc-push="left"] .site { translate: var(--ofc-push) 0; }.
  • The content is the default slot.
  • Tokens are --ofc-*, including --ofc-size and --ofc-z (110), which sits below the consent bar at 120. A modal panel lives in the top layer, like every modal dialog.

Accessibility contract.

  • The panel is named by its title.
  • Triggers get aria-controls and aria-expanded, plus aria-haspopup="dialog" in modal mode. A trigger that is not a link or button gets role="button" and tabindex="0", and opens on Enter or Space.
  • It closes from the 44px close button (closeLabel), a backdrop click, or Escape. A text selection dragged out of the panel does not close it. In push mode Escape works while focus is in the panel or on its trigger.
  • Focus moves into the panel on open and returns to the opener on close. Opening one panel closes any other.
  • Under reduced motion there is no slide and no fade. Otherwise it slides out as well as in.

Without JavaScript. The panel renders inline and visible where it sits, under its heading. A link trigger to #<id> jumps to it, and the close button is not shown. The script right after the panel takes it off the page before the first paint.

Off-canvas or mega-menu. mega-menu's own Menu drawer stays the site's main navigation on every screen size. Off-canvas is for what sits beside it: a cart, filters, an account menu. It is also for a secondary nav on a site whose main menu is plain links.

What the check pins.

  • The built page: each panel has an id, is not rendered open, is named by a heading with text, and has a type="button" close button with an aria-label that stays hidden until the script runs.
  • The demo: every panel has a trigger, every link trigger goes to #<id>, both modes appear, and at least three sides are shown.
  • The source:
    • a no-JS rule that renders the panel inline and visible;
    • showModal() plus the lock for modal mode, and show() plus --ofc-push for push mode;
    • the lock's scrollbar padding, the backdrop's press-origin check and focus return;
    • motion gated, --ofc-z under 120, a 44px close button, and the panel pair at 14.75:1.
  • Mutation test: 15 mutants, all caught.

Search. The page targets "off canvas" (140/mo, difficulty 35). Its alsoRanks are "off canvas menu" (70/24), "slide out panel" (70/6) and "push menu" (110/13). "side drawer" (480/7) and "slide out drawer" (590/19) are furniture. "flyout menu" (210/14) is main navigation, which is mega-menu's ground.

Verification

  • npm run check and npm run build are green: 25 elements, 25 pages.
  • I ran the website's type check against this branch in a scratch clone of ecropolis/SuperheroTech main (ELEMENTS_SOURCE=<this checkout> npm run check:types). It reports 0 errors and 0 warnings. The only new hint is countdown's "Props declared but never used", the same one announcement-bar gets from its exported time function.
  • The website also builds with this branch (ELEMENTS_SOURCE=… npm run build), its check:elements passes, and CSP hashes are generated for the new inline scripts. On /elements/, where the website moves inline scripts to external files, all nine instances mount.
  • In headless Chromium's accessibility tree each countdown is a timer named by its end, with no live setting, reading "7 days 5 hours 15 minutes 53 seconds".
  • I checked behaviour in headless Chromium through Playwright:
    • countdown: ticks; the evergreen start is kept on return and reset clears it; no cookie; fixed and evergreen ends swap to the message; the daily repeat rolls over at the cut-off; reduced motion; no-JS.
    • content-toggle: Space and Enter; label clicks; the session memory; ?billing=annual, =a and nonsense; the pressed buttons; the event; reduced motion; no-JS.
    • off-canvas: each side's geometry; focus on open and on return; Tab trapped; Escape, backdrop and close button; a drag-select that does not close; form method="dialog"; push publishing and clearing --ofc-push; one panel at a time; the scrollbar width kept (15px scrollbar, page unmoved); reduced motion; no-JS inline render and #id jump.
  • None of the three draws on licensed material, so none carries a license note. They use no Font Awesome icons; the close ×, like modal's, is a two-line SVG.

Not in this PR: each element's section in the superhero-ui-library skill (ecropolis/agent-skills), with its version bump. The README counts that as part of "finished".

🤖 Generated with Claude Code

ecropolis and others added 4 commits September 26, 2026 11:24
Days, hours, minutes and seconds to `to` in the business's `timeZone`
(announcement-bar's wall-clock parser, copied into its own `<cd-time>`
block), or an `evergreen` run whose start instant is kept in localStorage,
never a cookie, so a returning visitor sees the same deadline. Square,
circle or plain; at zero a message, hide, or repeat to the next day's or
week's occurrence across DST.

role="img" named "Ends Oct 3, 5:00 PM CDT", digits aria-hidden and updated
visually only. Without JavaScript the static "Ends <date>" line; the
build-time digits stay hidden because they would be stale. No tick
animation under reduced motion.

scripts/check-time-and-toggles.mjs pins the time goldens (DST gap,
half-hour zones, repeat next occurrence, evergreen start), the no-JS render
and ARIA contract on the built pages, and runs mutation tests against its
own rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Monthly or annual, homes or businesses: labels [a, b] on a
<button role="switch"> named by label b (aria-checked = b shown), or two
<button aria-pressed> in a named group when the labels are long. Panels in
slots a and b, an optional badge that also describes the switch, the choice
remembered for the session, and ?<param>=b (or the label) to preselect.

Without JavaScript both panels render under their labels as headings and
the switch is not shown; the script right after the element settles it
before the first paint. The knob does not slide and the panel does not fade
under reduced motion. notFor says why this is not tabs.

check-time-and-toggles.mjs pins the no-JS render, the switch and pressed
contracts, sessionStorage only, the fallback contrasts, and mutation-tests
those rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A native <dialog> from left, right, top or bottom, opened by any
data-offcanvas-open="<id>" trigger (aria-controls, aria-expanded, and
aria-haspopup in modal mode). Modal by default: showModal(), backdrop,
Escape, scroll lock that keeps the scrollbar width, a backdrop click that
ignores a dragged selection. Push: show(), no backdrop, and --ofc-push /
data-ofc-push published on <html> for the host to move its page. 44px close
button, focus back to the opener, one panel open at a time, --ofc-z 110
under the consent bar.

Without JavaScript the panel renders inline and visible where it sits, a
link trigger to #<id> jumps to it, and the close button is not shown; the
script right after the panel takes it off the page before the first paint.
notFor sends main mobile navigation to mega-menu and says when to use which.

check-time-and-toggles.mjs pins the no-JS render, the name, close button and
trigger contract on the built page, the modal/push/lock/backdrop rules in
the source, and mutation-tests them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
timer's implicit aria-live is "off", so it announces nothing as it ticks
without any aria-live attribute, and it is the role for this content. The
wrapper keeps the one absolute-end aria-label ("Ends Oct 3, 5:00 PM CDT").
role="img" turned real text into a picture; now the digits are reachable
for anyone who navigates into them.

Each unit reads as its number and its word: the zero-padded number is
aria-hidden and an unpadded copy is read instead ("3 days", not "zero
three"). The unit words use small capitals rather than text-transform,
which Chrome passes to screen readers as "DAYS".

The check now requires role="timer" and no aria-live attribute, readable
digits with an unpadded number and a unit word each; the mutants flip to
match (20 caught).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ecropolis
ecropolis merged commit 7e41ec0 into main Sep 26, 2026
2 checks passed
@ecropolis
ecropolis deleted the feat/countdown-toggle-offcanvas branch September 26, 2026 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant