Web Components

This page covers five custom elements: <validation-error>, <toasts-area>, <title-display>, the masked <date-input> date field, and the <markdown-editor> rich-text field. They're plain web components - no framework, no shadow DOM dependency beyond what the platform provides - and they're loaded as static scripts in your layout. Each one solves a specific problem that isn't worth a framework component but is worth abstracting away from every page that needs it.

This page is the inventory. The first two have full coverage in their dedicated sections; this is the place to look when you want to know what's available and what each element does.

Loading

The elements live in static/web-components/. Load whichever ones the page needs in your layout's <head>:

<script src="/web-components/validation-error.js" defer></script>
<script src="/web-components/toasts-area.js" defer></script>
<script src="/web-components/title-display.js" defer></script>
<script src="/web-components/date-input.js" defer></script>
<script src="/web-components/markdown-editor.js" defer></script>

Each script registers its custom element with customElements.define() on load. After that, the element is usable anywhere on the page - there's no per-page initialisation.

validation-error

Displays a single per-field validation error inline. Used in every form throughout the application; full integration details are in Validation.

<input type="text" id="email" name="email" />
<validation-error id="error-email"> {= props.errors.email } </validation-error>

The element is a thin shadow-DOM wrapper around a <slot>. It re-renders when its slotted content changes, which is what makes the live-validation pattern work - FormController sets the element's innerHTML and the element picks up the change.

When the slotted content is empty, the element renders nothing visible. There's no display: none to manage; an unused <validation-error> simply takes up no visual space.

toasts-area

A fixed-position container that displays toast notifications stacked at the bottom of the viewport. Used for confirming saves, surfacing errors after a redirect, and any other "something happened, here's a brief notification" pattern. Full integration details are in Toast Notifications.

<toasts-area id="toasts-area"></toasts-area>

Place it once near the end of your <body>. The element exposes an add_toast(toast) method and a global window.add_toast(toast) alias for adding notifications from any script:

add_toast({
    type: "green",
    message: "Saved successfully",
    duration: 2000,
});

The element manages auto-expiry (each toast self-removes after its duration) and stacks multiple toasts with a small staggered animation.

title-display

A small element for projecting a string with optional capitalization or pluralization, useful for keeping headings in sync with feature names without duplicating the string in code.

<title-display capitalize pluralize>user</title-display>
<!-- renders: Users -->

<title-display capitalize>email</title-display>
<!-- renders: Email -->

<title-display pluralize>category</title-display>
<!-- renders: categories -->

The two boolean attributes:

  • capitalize - uppercase the first character.
  • pluralize - naive English pluralization (y → ies, otherwise add s).

The element observes its slotted text content with a MutationObserver, so changing the text dynamically re-renders the output:

document.querySelector("title-display").textContent = "project";

The pluralization is intentionally simple - it covers most database table names (user → users, category → categories) and falls down on irregular plurals (person → persons, not people). For anything beyond that, render the right string on the server using the route's translations.

date-input

A form-participating masked date field. Instead of the native picker, it renders the date as separate day / month / year segments in a locale-specific order and format (e.g. dd. mm. yyyy for sl-si, mm/dd/yyyy for en-us), lets the user type digits into each segment, and commits a real ISO YYYY-MM-DD value to a hidden <input> once the date is complete and valid.

<date-input
    id="published_on"
    name="published_on"
    value="2026-08-01"
    locale="{= props.locale }"
    min="1900-01-01"
    max="2030-12-31"
    ...props.translations.errors
></date-input>
<validation-error id="error-published_on"></validation-error>

The generated date.ree field template and the input-date-masked component both use <date-input>; see Input Components → The Masked date-input Field for form wiring and Validation → Date Codecs for the server-side schema that pairs with it.

How It Works

The element wraps a real <input type="hidden" name="..."> in its light DOM - no shadow root - so FormController's input[name] discovery and the <validation-error> wiring keep working unchanged (the same approach as markdown-editor).

Locale layout. The segment order and separators come from Intl.DateTimeFormat.formatToParts() for the locale attribute (falling back to document.documentElement.lang):

const formatter = new Intl.DateTimeFormat(locale, { day: "2-digit", month: "2-digit", year: "numeric" });
const parts = formatter.formatToParts(new Date(2000, 0, 1));
// sl-si -> "01. 01. 2000"  -> segments: day, ". ", month, ". ", year
// en-us -> "01/01/2000"    -> segments: month, "/", day, "/", year

Each segment renders as a <span class="date-input-segment"> padded to its full width with underscores (____ for the year), and the focused segment gets the is-active class.

Typing. Digits fill the focused segment; when it's full the focus auto-advances to the next segment. A two-digit year is accepted as shorthand and expanded on blur (and when navigating between segments) - 26 becomes 2026 (the rule: 00–68 → 2000s, 69–99 → 1900s). ArrowLeft / ArrowRight move between segments, Backspace deletes the last digit of the active segment, and pasting a date (e.g. 15. 08. 2026 or 08/15/2026) fills the segments from the digits.

Validation on blur. The component validates on focusout, writing the message into the nearest <validation-error> element. It checks required, calendar validity (e.g. rejects February 30), and the min / max bounds:

ConditionAttribute readSeed key (root)
Required but emptydate_requirederrors.date_required
Not a real calendar dateinvalid_dateerrors.invalid_date
Before mindate_min (has {min})errors.date_min
After maxdate_max (has {max})errors.date_max

The generated field spreads ...props.translations.errors onto the element, so each attribute is the active locale's translated message (see Translations → Error Messages). The value is only committed to the hidden input when validation passes - an invalid in-progress edit leaves the previously committed value intact, and the element dispatches a bubbling input event on commit so signal-driven state can bind to it (see Signals).

Styling lives in css/date-input.css (.date-input-display, .date-input-segment, .is-active) alongside the other component styles.

markdown-editor

A form-participating WYSIWYG markdown editor. It renders the field's markdown as formatted content on a contenteditable surface, exposes a formatting toolbar, and serializes the edited content back into a real <textarea> on every change - so the form submits plain markdown, not HTML.

<markdown-editor id="body" name="body" value="{= record.body }" placeholder="Write something…"></markdown-editor>
<validation-error id="error-body"></validation-error>

The generator emits <markdown-editor> for columns commented markdown - see Input Components → Markdown Editor for the generated form wiring and Generators for the column hint.

Form Participation

The element wraps a real <textarea name="..."> in its light DOM - no shadow root - so FormController's textarea[name] discovery and the <validation-error> wiring keep working unchanged, exactly like <date-input> wraps a hidden input. The contenteditable surface is purely visual: every input event on it serializes the content back to markdown, writes it to the hidden textarea, and dispatches a bubbling input event so the form sees the change (the same sync pattern file-upload.js uses for its hidden field). The initial value comes from the value attribute, falling back to the element's text content; a placeholder attribute renders via CSS when the surface is empty.

The Toolbar

The toolbar (TOOLBAR_BUTTONS in the source) runs document.execCommand against the focused surface:

ButtonCommandAction
BboldBold (**…**)
IitalicItalic (*…*)
</>codeInline code (`…`) - toggles wrap/unwrap
🔗linkPrompt for a URL, wrap selection as a link
H1 H2 H3h1…h3Block-level heading
•ulBulleted list
1.olNumbered list
❝quoteBlockquote
MDrawToggle raw markdown editing mode

Inline-format commands apply to the current selection; list and heading commands apply to the current block.

Raw Markdown Mode

The MD button toggles raw mode: the contenteditable surface is hidden and the plain <textarea> is revealed, so you can edit the markdown source directly. In raw mode the same toolbar buttons operate on the textarea's selection instead - wrapping in ** / * / backticks, prompting for a link URL, and prefixing lines with #, -, 1., or > as appropriate. Toggling back re-renders the surface from the textarea's markdown.

Copy and Paste

Copy and paste are intercepted so markdown survives round-trips:

  • Copy puts the selection (or the whole document when everything is selected) on the clipboard in three formats - a private application/x-reepolee-markdown type, text/plain, and text/html - so pasting between editors keeps the markdown, while pasting into a plain app still works.
  • Paste honours the private markdown type unconditionally (it only ever comes from this editor's own copy handler), and otherwise falls back to text/plain - which is only intercepted when it looks like markdown (heading/list/quote prefixes or **bold**). Plain prose pastes natively through the browser's default handler.

Supported Subset

The editor round-trips the same subset the server renders with Bun.markdown.html(): headings h1–h3, bold, italic, inline code, links, unordered/ordered lists, and blockquotes. Anything outside that subset - tables, images, code fences - is not guaranteed to survive the HTML → markdown serialization; author them in raw mode if you need them.

Styling lives in css/markdown-editor.css (.markdown-editor-toolbar, .markdown-editor-btn, .markdown-editor-surface).

Why Shadow DOM

<validation-error> and <title-display> use shadow DOM for style encapsulation. The shadow root keeps Tailwind's global resets and the application's CSS from accidentally restyling the element's internals - so the same <validation-error> looks the same whether it's on a CRUD form or a marketing page. <toasts-area> doesn't use shadow DOM; its children pick up the page's styles intentionally so toasts match the surrounding theme. <date-input> and <markdown-editor> also skip shadow DOM - for a different reason: they need their hidden <input> / <textarea> to participate in the page's form, so their internals live in the light DOM where FormController can find them.

If you're writing your own custom element, the rough rule:

  • Shadow DOM if the element has internal markup that must look the same regardless of where it's used.
  • No shadow DOM if the element's children should inherit page styles (theming, typography).

Writing Your Own Custom Element

A minimal custom element is around twenty lines. The element class extends HTMLElement, optionally attaches a shadow root in the constructor, and renders in connectedCallback:

class CopyButton extends HTMLElement {
    connectedCallback() {
        const text = this.getAttribute("text") || "";

        this.innerHTML = `<button>Copy</button>`;
        this.querySelector("button").addEventListener("click", () => {
            navigator.clipboard.writeText(text);
            this.querySelector("button").textContent = "Copied!";
            setTimeout(() => {
                this.querySelector("button").textContent = "Copy";
            }, 1500);
        });
    }
}

customElements.define("copy-button", CopyButton);
<copy-button text="https://www.reepolee.com"></copy-button>

A few conventions worth following:

  • Element names must contain a hyphen. That's the rule that keeps custom elements from colliding with future native elements.
  • Initialise in connectedCallback, not the constructor. Attributes and children may not be available yet when the constructor runs.
  • Read attributes with getAttribute rather than properties - properties on a custom element only exist if you define them.
  • Save the file in static/web-components/ and load it in your layout for project-wide availability, or inline the script in a single template if it's only used on one page.

For elements that need to react to attribute changes after registration, implement static get observedAttributes() and attributeChangedCallback - <validation-error>, <title-display>, and <markdown-editor> (which observes disabled) all use this pattern; their source is the most readable starting point.

Other Bundled Utilities

Reepolee also ships a small set of vanilla JS classes that aren't custom elements but live alongside them in static/:

  • form-controller.js - live validation and submit interception. See Validation.
  • checkbox-group.js - bulk-select tables (select all / deselect all / enable action buttons when any row is checked). Used in generated CRUD list views.
  • dialog-confirm.js - tiny global handler that turns [data-dialog-confirm] buttons into a "confirm" event on their enclosing <dialog>. Open/close use native HTML commandfor + command attributes (no JS). See Dialogs.
  • helpers-client.js - $, $$, and a few small DOM helpers loaded globally.

Each one is small enough to read in one sitting. When you need to know exactly what's happening, the source is in static/.