Builtin conventions¶
Every pyjinhx builtin follows the same contract, so knowing one means knowing all of them.
The contract¶
idis optional. Omit it and pyjinhx generatespjx-<n>. Pass one when you need a stable hook (CSS, htmx targets) — and always for reactive components, whose OOB targeting requires stable identity across renders.class_nameappends your classes to the root element:PJXBadge(label="New", class_name="pill").extra_attrspasses extra attributes (validated — values may not contain") onto the root element — the carrier forhx-*,data-*,aria-*, or Alpine directives:PJXAlert(body="Prices may change.", extra_attrs={"hx-get": "/refresh", "hx-trigger": "every 30s"}). Any attribute passed inline on a PascalCase tag is also injected onto the root automatically (see Attribute pass-through below). The newer structural builtins (PJXIcon,PJXAccordion) intentionally omit theextra_attrsfield — inline tag attributes (<PJXIcon Hx-Post="/save"/>) still pass through to the root, but the dict-styleextra_attrs={...}API is not available on them; use inline attributes orclass_nameinstead.- All copy is props. Every user-visible string, aria-labels included, has an English default
you can replace:
PJXModalHeader(title="Excluir?", close_label="Fechar"). - JS is headless. Builtin JavaScript never writes inline styles for state — visibility and variants are classes/attributes; computed positioning coordinates (tooltip/popover placement) are the one sanctioned inline-style use. Communication is through
pjx:*DOM events anddata-pjx-*attributes; programmatic APIs live under the singlewindow.pjxnamespace. An internal helper defaults to exposed, not closure-private, whenever a consuming app is likely to want to compose it — DOM construction for a builtin's own markup shape (pjx.buildChip), geometry/positioning math (pjx.popoverPosition), and the like. Keep the helper's own name as itspjx.*property name (pjx.popoverPosition = popoverPosition;); only reach for a per-builtin namespace object (pjx.modal,pjx.drawer) when there's more than one related entry point to group. Async-state JS follows the runtime's concurrency pattern: a ref-count per scope, release keyed to each request'sloadend(terminal on load, error, abort, and timeout), and state re-applied afterhtmx:afterSettlefor nodes a swap replaced mid-flight. - The DOM contract is API. Each builtin's documentation ends with a "DOM contract" block —
stable classes,
data-pjx-*attributes, events, state attributes. We version those like code. - Output is escaped by default. Scalar props, text, attributes, and loop values are
HTML-escaped. A builtin's children/
contentfield and any field typedSlot(e.g.PJXCardBody.content, a tab group'stabs) render raw HTML, as do nestedBaseComponentvalues. See Escaping & slots for the full rule and escape hatches. - Type matches escaping. A field's annotation must reflect whether it renders raw: text
fields (titles, labels, descriptions) are plain
strand stay escaped; raw-HTML/icon/component fields areSlot(or the children field). A field is never typedstr | BaseComponentunless it is a slot — otherwise a component renders raw while a string escapes, an inconsistency that is also an XSS footgun.tests/pyjinhx/test_slot_type_v2.pyenforces this.
Events and hooks¶
Interactive builtins fire a two-tier event vocabulary on their root element (all bubble):
pjx:<component>:before-<verb>— cancelable.event.preventDefault()aborts the action.detail = {reason, trigger}withreason ∈ {"escape","backdrop","api","trigger"}.pjx:<component>:<verb>— fired after the DOM change. Not cancelable.
Shared vocabulary: pjx:before-reveal / pjx:reveal fire on any [data-pjx-region]
(PJXTabPanel bodies) when it is shown — PJXLazyLoad(when="reveal") builds on it; pjx:toast is the
input event PJXToastHost listens for (htmx fires it from HX-Trigger response headers).
document.getElementById("confirm-del").addEventListener("pjx:modal:before-close", (e) => {
if (hasUnsavedChanges()) e.preventDefault();
});
Declarative attributes¶
| attribute | effect |
|---|---|
data-pjx-open="<id>" |
click opens that PJXModal / PJXDrawer |
data-pjx-close |
click closes the nearest enclosing dismissible |
data-pjx-toggle="<id>" |
click toggles that PJXPopover / PJXDropdown menu |
data-pjx-loader |
requests from this subtree show the PJXPageLoader |
data-pjx-region |
marks a show/hide region (emits pjx:reveal) |
data-pjx-autoshow |
PJXNotification auto-shows when this attribute is present on mount |
The window.pjx namespace¶
pjx.modal · pjx.drawer · pjx.popover · pjx.notification · pjx.loader.region (region busy-state) ·
pjx.pageLoader (page navigation) · pjx.toast · pjx.popoverPosition (trigger/panel/viewport
geometry) · pjx.buildChip (a chip's label/hidden-input/remove-button markup). Open/close/show/hide
functions return false when a before-* hook canceled the action.
Attribute pass-through¶
Inline tag attributes that are not declared fields of a component are automatically injected
onto that component's root element. This applies to every component — builtins,
BaseComponent subclasses, and template-only components created with component() — with no
template boilerplate required.
<!-- hx-get and data-label pass through to the root of PJXCard automatically -->
<PJXCard id="my-card" hx-get="/orders" hx-trigger="every 5s" data-label="orders-panel"/>
Override semantics: an inline attribute replaces any same-named attribute the template
already hardcodes on its root, including class and style (full replace, not merge).
Props vs. pass-through: declared fields (Python class attributes) are consumed as props —
they flow into the template context and are not injected onto the root. Only non-declared
("stray") attributes and explicit extra_attrs are injected. For template-only components
(no declared fields), all attributes inject onto the root and are also available as template
variables.
For builtins specifically, class_name is the right way to append CSS classes (it concatenates onto the
template's root class). Use inline class="..." or extra_attrs={"class": "..."} only when
you want to replace the root class entirely.
Accessibility¶
pyjinhx builtins replace a third-party component library, so they own the WAI-ARIA baseline a library would otherwise amortize across its users. The contract, by pattern (see the WAI-ARIA APG for the authoritative spec per widget):
- Native elements first.
PJXAccordion(<details>/<summary>) andPJXModal/PJXDrawer(<dialog>+showModal()) get keyboard interaction, focus trapping, and — for dialogs — an implicitrole="dialog"/aria-modal="true"for free from the browser. Prefer a native element over hand-rolled ARIA state whenever one fits the pattern. - Disabled means non-operable, not just inert.
aria-disabled+tabindex="-1"alone doesn't stop a native element's default action (e.g. a<summary>still toggles on Enter/Space); cancel the default action explicitly, asPJXAccordionTrigger's JS does. - Accessible names for icon-only/non-text content go through the same attribute
pass-through as everything else —
aria-label/aria-labelledbyneed no dedicated prop. Where a template composes two builtins that need to reference each other (e.g.PJXModal+PJXModalHeader's title), the referenced part exposes a predictableid("{id}-title") rather than the pair auto-wiring a hidden dependency. prefers-reduced-motionis honored on any transform/opacity transition tied to open/close state (modal, drawer, notification, toast, region-loader, accordion chevron) by clampinganimation-duration/transitionunder the media query — not by removing the animation, since some of that lifecycle is driven byanimationend. Continuous progress indicators (PJXSpinner,PJXSkeleton,PJXPageLoader) are exempt: they convey ongoing state, which WCAG 2.3.3 treats as essential motion.
Single-root rule¶
Every component template must render exactly one top-level HTML element. Rendering a
template that produces zero or two or more sibling top-level elements raises a ValueError
naming the component. This is enforced at render time so conditional roots resolve
naturally:
{# OK — renders to exactly one root regardless of branch taken #}
{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<button>{{ label }}</button>{% endif %}
Comments and whitespace surrounding the root element are ignored. Fragments (React-style
<>…</>) are not supported.
Theming¶
Builtins read --pjx-* tokens that default to your app-level semantic tokens. Re-skin globally in
one block:
or per-context: .settings-pane { --pjx-card-bg: var(--surface-alt); }.