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 (yies, 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 (userusers, categorycategories) and falls down on irregular plurals (personpersons, 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: 0068 → 2000s, 6999 → 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 H3h1h3Block-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 h1h3, 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/.