Skip to content

Web components

Astropress ships a set of vanilla custom elements for the admin UI. They are importable individually, tree-shakeable, usable outside the Astropress package, and extendable via class inheritance.

// Register all built-in elements (side-effect import)
import "astropress/web-components";
// Or import individual elements (tree-shakeable)
import "astropress/web-components/theme-toggle";
import "astropress/web-components/confirm-dialog";
import "astropress/web-components/html-editor";
import "astropress/web-components/admin-nav";
import "astropress/web-components/ap-stale-tab-warning";
import "astropress/web-components/notice";

Each import registers the custom element globally via customElements.define(). Import them in a <script> tag inside your .astro component.

Wraps a button to provide light/dark theme switching. Reads and writes localStorage key "theme". Syncs all instances on the page. Responds to html[data-theme] via existing CSS variables — no shadow DOM needed.

<ap-theme-toggle label-dark="Switch to dark mode" label-light="Switch to light mode">
<button type="button" class="theme-toggle-admin" aria-pressed="false">
<span class="theme-toggle-icon" aria-hidden="true"></span>
</button>
</ap-theme-toggle>
<script>
import "astropress/web-components/theme-toggle";
</script>

Attributes:

AttributeDescription
label-darkaria-label shown when theme is currently light (clicking will go dark)
label-lightaria-label shown when theme is currently dark (clicking will go light)

A generic confirmation dialog that responds to trigger buttons anywhere on the page. Replaces both comments-dialog.ts and redirects-dialog.ts with one element.

<!-- The dialog (place near the bottom of your page) -->
<ap-confirm-dialog>
<dialog id="delete-dialog" class="confirm-modal" aria-labelledby="delete-dialog-title">
<div class="modal-content">
<h2 id="delete-dialog-title">Delete item?</h2>
<p>This action cannot be undone.</p>
<p><strong id="item-name"></strong></p>
<div class="modal-actions">
<button type="button" data-dialog-close>Cancel</button>
<form method="post" action="/ap-admin/actions/item-delete">
<input type="hidden" name="itemId" value="" />
<button type="submit">Delete</button>
</form>
</div>
</div>
</dialog>
</ap-confirm-dialog>
<!-- The trigger (can be anywhere on the page) -->
<button
type="button"
data-confirm-trigger
data-dialog-id="delete-dialog"
data-text-item-name="My Item"
data-field-name="itemId"
data-field-value="abc-123"
>
Delete
</button>
<script>
import "astropress/web-components/confirm-dialog";
</script>

Trigger attributes:

AttributeDescription
data-confirm-triggerMarks this button as a dialog trigger
data-dialog-idID of the <dialog> to open
data-text-[element-id]Sets textContent on #[element-id] inside the dialog
data-field-nameName of the <input> to populate inside the dialog’s form
data-field-valueValue to set on that input

Close triggers: Any element with data-dialog-close closes the dialog when clicked.


Wraps a textarea editor with a formatting toolbar, live preview iframe, media library dialog, and a URL input dialog.

<ap-html-editor>
<form method="post" action="/ap-admin/actions/content-save">
<div role="toolbar" aria-label="Format body">
<button type="button" data-cmd="bold" aria-label="Bold"><strong>B</strong></button>
<button type="button" data-cmd="italic" aria-label="Italic"><em>I</em></button>
<button type="button" data-cmd="insertUnorderedList" aria-label="Bullet list">List</button>
<button type="button" data-cmd="createLink" aria-label="Insert link">Link</button>
<button type="button" class="insert-media-btn" aria-label="Open media library">Media</button>
</div>
<textarea data-body-editor name="body" rows="14"></textarea>
<div class="preview-frame">
<iframe sandbox="" title="Rendered preview"></iframe>
</div>
</form>
<!-- URL input dialog — place outside the form to avoid nested-form issues -->
<dialog id="url-input-dialog" aria-labelledby="url-input-title">
<h2 id="url-input-title">Insert link</h2>
<form id="url-input-form" method="dialog">
<input id="url-input-field" type="url" name="url" placeholder="https://" />
<button type="button" data-dialog-close>Cancel</button>
<button type="submit">Insert link</button>
</form>
</dialog>
<!-- Media library dialog -->
<dialog id="media-library-dialog" aria-labelledby="media-dialog-title">
<h2 id="media-dialog-title">Media Library</h2>
<button id="media-dialog-close" type="button">Close</button>
</dialog>
</ap-html-editor>
<script>
import "astropress/web-components/html-editor";
</script>

The sidebar navigation element. Highlights the current page using aria-current="page" and supports keyboard navigation between links.

<ap-admin-nav>
<nav aria-label="Main navigation">
<ul>
<li><a href="/ap-admin" aria-current="page">Dashboard</a></li>
<li><a href="/ap-admin/posts">Posts</a></li>
<li><a href="/ap-admin/media">Media</a></li>
</ul>
</nav>
</ap-admin-nav>
<script>
import "astropress/web-components/admin-nav";
</script>

The component derives aria-current from window.location.pathname — no attribute needed.


Shows an accessible warning banner when another browser tab is editing the same content, or when the page has been open longer than the session TTL.

Uses the BroadcastChannel API — cross-tab communication with no server round-trips.

<ap-stale-tab-warning
slug="my-post-slug"
session-ttl-ms="3600000"
>
</ap-stale-tab-warning>
<script>
import "astropress/web-components/ap-stale-tab-warning";
</script>

Attributes:

AttributeDefaultDescription
slugIdentifies the content being edited
session-ttl-ms3600000 (1 hr)Shows a reload warning after this many ms without a page refresh

When triggered, renders a role="alert" banner — screen readers announce it immediately.


A transient notification banner with an accessible live region.

<ap-notice dismiss-after="4000">Post saved.</ap-notice>
<script>
import "astropress/web-components/notice";
</script>

Attributes:

AttributeDefaultDescription
dismiss-afterRemoves the element after this many milliseconds

The element renders with role="status" and aria-live="polite". Create notices dynamically:

const notice = document.createElement("ap-notice");
notice.setAttribute("dismiss-after", "3000");
notice.textContent = "Saved!";
document.body.appendChild(notice);

  • Light DOM — no attachShadow(). Admin CSS custom properties inherit directly without ::part() selectors.
  • Progressive enhancement — server renders semantic HTML; the WC adds the interactive layer on the client.
  • Attribute-driven state — server passes initial state via HTML attributes.
  • AbortController for cleanup — each WC stores an AbortController in connectedCallback and calls .abort() in disconnectedCallback.

import { ApConfirmDialog } from "astropress/web-components/confirm-dialog";
class MyConfirmDialog extends ApConfirmDialog {
connectedCallback() {
super.connectedCallback();
// Add custom behavior here
}
}
customElements.define("my-confirm-dialog", MyConfirmDialog);

export class ApMyElement extends HTMLElement {
private _abortController: AbortController | null = null;
connectedCallback() {
this._abortController = new AbortController();
const { signal } = this._abortController;
this.querySelector("button")?.addEventListener("click", () => {
// handle click
}, { signal });
}
disconnectedCallback() {
this._abortController?.abort();
this._abortController = null;
}
}
customElements.define("ap-my-element", ApMyElement);

Key rules: light DOM only, use this.querySelector(), pass { signal } to every addEventListener, export the class, use ap- prefix.


The admin panel is fully navigable with screen readers.

Landmarks: <header>, <nav aria-label="Main navigation">, <main>, <footer>. Use your screen reader’s landmark navigation shortcut.

Headings: Each page has a structured heading hierarchy (<h1> page title, <h2> sections). Use heading navigation to jump to sections.

Forms: All controls have explicit <label> elements or aria-label attributes. Error messages are associated via aria-describedby.

Dialogs: <ap-confirm-dialog> uses the native <dialog> element — focus moves in on open, Escape closes, focus returns to trigger.

Skip link: A visually-hidden skip link (Skip to main content) appears at the top of every admin page and becomes visible on keyboard focus.