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 adds).
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:
| Condition | Attribute read | Seed key (root) |
|---|---|---|
| Required but empty | date_required | errors.date_required |
| Not a real calendar date | invalid_date | errors.invalid_date |
Before min | date_min (has {min}) | errors.date_min |
After max | date_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:
| Button | Command | Action |
|---|---|---|
B | bold | Bold (**…**) |
I | italic | Italic (*…*) |
</> | code | Inline code (`…`) - toggles wrap/unwrap |
🔗 | link | Prompt for a URL, wrap selection as a link |
H1 H2 H3 | h1…h3 | Block-level heading |
• | ul | Bulleted list |
1. | ol | Numbered list |
❝ | quote | Blockquote |
MD | raw | Toggle 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-markdowntype,text/plain, andtext/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
getAttributerather 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 HTMLcommandfor+commandattributes (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/.