Declarative Widgets (juneau-console-widgets)
juneau-console-widgets is a small, optional module of declarative widget beans: plain Java objects that
describe a dialog, a form, a calendar, a row of stat tiles or a badge, plus the first-party browser scripts that paint
them. A bean carries data and a fail-closed validate(); it carries no HTML-emitting code. The server-side emitters
live in juneau-console-views, which composes these beans, and the dialog/form/bar runtime ships with that module's
juneau-views.js.
$W{...} widgetsJuneau has two unrelated things called "widget":
| This page | 10.31.2 Widgets / 10.31.3 Predefined Widgets | |
|---|---|---|
| Module | juneau-console-widgets | juneau-rest-server |
| Package | org.apache.juneau.console.widgets (plural) | org.apache.juneau.rest.server.widget (singular) |
| Core type | Widget — a marker interface with validate() | Widget — an abstract class with getHtml() / getStyle() / getScript() |
| What it is | Typed, data-only beans (modal, form, calendar, badges) | Code that emits raw HTML/CSS/JS into an @HtmlDocConfig page via $W{Name} |
| Safety model | Typed fields painted with textContent; never an HTML sink | Raw markup authored by the application |
The two share nothing but a simple class name, and a Widget in your imports is ambiguous if you use both. Import
one by its fully-qualified name.
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-console-widgets</artifactId>
<version>${juneau.version}</version>
</dependency>
The module depends only on juneau-rest-server and juneau-commons. It deliberately has no dependency on
juneau-console-views, so a widget bean can never import a views type.
The script this module ships (juneau-calendar.js) resolves its icons through the views
module's juneau-icons.js and pushes its popovers onto the single layer stack that juneau-views.js publishes. A page
that loads it must load both of those too. See the WidgetsMixin section below.
The Widget contract
Widget is a one-method marker interface:
public interface Widget {
void validate(); // throws IllegalArgumentException if not well-formed
}
Implementers: ModalDef, FormDef, CalendarDef, BarSlot and ServerValues.
Every one of them is fail-closed. validate() rejects a malformed bean rather than repairing it, and the REST
serving path calls it for you (see the validation gate).
Beans that ride the wire also carry a CONTRACT_VERSION string that the browser runtime compares against a literal it
bakes in; a mismatch refuses to paint rather than painting something subtly wrong.
| Bean | CONTRACT_VERSION |
|---|---|
ModalDef | "2" |
FormDef | "2" |
CalendarDef | "2" |
BarSlot | "1" |
ServerValues | "1" (Java-only, never on the wire) |
ModalDef and FormDef move in lockstep with each other and with the runtime; bumping any two of the three makes every
form-bearing dialog refuse to open.
Type catalog
| Group | Types |
|---|---|
| Dialogs and forms | ModalDef (+ Field, ChildAction), FormDef (+ Input, Input.Option, Section) |
| Calendar | CalendarDef (+ CalendarView, WeekStart), CalendarEvent, EventCategory (+ CategoryColor) |
| Bar slot | BarSlot, BarWidget (sealed), BarBadge, BarText |
| Badges and menus | Badge, StatusTone, MenuItem |
| Actions | ActionBarItem, ActionRef (+ Emphasis, EnabledRule), SafeAction, Behavior, Op |
| Server values | ServerValues, ServerValuesValue, ServerValuesRegistry |
| Serving | WidgetsMixin (+ WidgetValidationProcessor) |
Dialogs: ModalDef
ModalDef is the
payload a row action with present=dialog opens: the response to the modal-open confirmation fetch. It is a REST
response body, so you return one from an ordinary @RestGet.
@RestGet("/incidents/{id}/ack-dialog")
public ModalDef ackDialog(@Path("id") String id) {
var incident = incidents.get(id);
return ModalDef.create("Acknowledge this incident?")
.field("Incident", incident.number())
.field("Title", incident.title())
.field("Service", incident.service())
.codeField("Correlation id", incident.correlationId())
.form(FormDef.create()
.field(FormDef.Input.of("comment", "Comment", "textarea").maxLength(500)))
.idempotencyKey(keys.mint("ack", id));
}
Safe by construction
The confirmation body is typed fields, not markup. Each ModalDef.Field is a label and a value, painted by the
runtime with textContent, never innerHTML. Live data (an incident title anyone can edit) is attacker-influenceable
and the page's origin holds the CSRF token, so an HTML sink here would be stored XSS leading to arbitrary writes. There
is no way to express one.
Field.kind is "text" (the default) or "code" (monospaced and copyable — an id, a URL, a token). Use
.field(label, value) and .codeField(label, value) respectively.
Properties
| Property | Meaning |
|---|---|
contractVersion | Always "2" from construction. |
title | The prompt / title. Required, non-blank. |
fields | The typed confirmation fields, in display order. |
form | Optional FormDef. Omit for a confirm-only dialog. |
idempotencyKey | A server-minted key, bound to the action and target, that the runtime echoes verbatim on submit so a double-click or retry collapses to one effect. |
selfTargeted | Opt-in: the key targets itself (a dialog with no row, whose submit creates the thing). See the warning below. |
keepOpenOnSubmit | Opt-in: do not close at confirm-click; paint the write's result into the open dialog as a receipt. |
childActions | A catalog of actions scoped to this one open dialog, for action inputs that open a stacked step. |
barSlot | An optional BarSlot painted immediately after the title. Additive; does not bump the contract version. |
selfTargeted discards the real targetSet selfTargeted(true) only on a modal whose key came from the self-targeted mint helper. On a modal carrying an
artifact-bound key it silently replaces the real target with the key itself, which makes the server's binding check
vacuous. It defaults to unset, and a dialog that leaves it unset keeps sending the row's real id.
Receipts: keepOpenOnSubmit
By default the runtime closes a dialog at confirm time and then submits, so the dialog is gone before the result exists.
keepOpenOnSubmit suppresses the close and paints the result into the open dialog. A receipt dialog is
non-submittable by construction (it has no confirm control, and the runtime refuses to paint one whose payload carries a
form), and it is reached only after a committed write. A dialog synthesized from a confirm string alone cannot opt
in; a receipt-bearing action must serve a real ModalDef. An older runtime that ignores the flag closes the dialog and
drops the receipt; the write still happens.
Stacked steps: childActions
A type="action" form input names an action id, which the runtime normally resolves against the enclosing view's
row-action catalog. A step reached from inside an open dialog (a Review step in a wizard) is not a row action, and
publishing it as one would surface it in every row's menu. ModalDef.ChildAction is that missing catalog, scoped
structurally to one open dialog because it rides on the per-open payload. The existing row-action lookup runs first and
unchanged, so a served payload can never shadow a declared row action.
ModalDef.create("Create rule")
.form(FormDef.create().field(FormDef.Input.of("review", "Review", "action").actionId("review")))
.childAction(ModalDef.ChildAction.of("review", "Review")
.form("/rules/review-form")
.endpoint("/rules")
.method("POST")
.carryDrafts(true));
ChildAction has id, label, form (the child's form-source URL), endpoint, method, onSuccess and
carryDrafts. carryDrafts sends the parent dialog's current values to the child's form GET as one URL-encoded
juneauDrafts query parameter, so a Review step does not open empty. It requires both a form on the parent and a
form URL on the child.
Never carry a secret through carryDrafts.
Forms: FormDef
FormDef is the input
form of a modal. A form is either a flat fields list or an ordered list of sections, never both (the flat list
is the single-section case). Sections render as a strip above one visible pane; they are a layout of one dialog, not a
stack of dialogs, and a value typed into one section survives a trip through another.
var form = FormDef.create()
.field(FormDef.Input.of("resolution", "Resolution comment", "textarea").required().maxLength(1000))
.field(FormDef.Input.of("severity", "Severity", "select")
.option("low", "Low").option("high", "High").value("low"))
.field(FormDef.Input.of("notify", "Notify owner", "toggle"));
// Sectioned:
var wizard = FormDef.create()
.section(FormDef.Section.of("basics", "Basics")
.field(FormDef.Input.of("name", "Name", "text").required()))
.section(FormDef.Section.of("advanced", "Advanced")
.field(FormDef.Input.of("retries", "Retries", "text").pattern("[0-9]+")));
Input types
The type allowlist is the XSS bound: hostile prefill cannot become an element. The client paints native controls with
createElement, uses textContent for labels and options, and .value / .checked for prefills.
type | Notes |
|---|---|
text, textarea | The default is text. Only these two accept pattern and maxLength. |
password | A masked, write-only input (<input type="password" autocomplete="new-password">). It is never pre-filled, so it is blank on edit and on re-open; validate() rejects a value on a password input, and it does not accept pattern or maxLength. Its typed value is excluded from the carryDrafts draft query, so it never lands in a URL. |
checkbox, toggle | Boolean inputs. |
select | Requires at least one Option; a prefilled value must match an option. options is legal only here. |
action | A button that opens the row action (or child action) named by actionId. actionId is required here and illegal elsewhere. Use .action(ActionRef) or .actionId(String). |
Input properties: name (the submit-body key, required), label (required), type, required, value, options,
pattern, maxLength, help, actionId. pattern is capped at 256 characters and must compile (ReDoS
defense in depth); maxLength must be positive.
FormDef.template names an optional server-side template for authors and is ignored by the client (not an HTML
sink). Create such a form with FormDef.ofTemplate(String).
validate() additionally rejects duplicate field names (across all sections of a sectioned form), duplicate section
ids, and an empty sections list.
Calendars: CalendarDef
CalendarDef
describes a month calendar with navigation, categories and a display-only legend. The emitter (CalendarTable) is in the
views module; the page mounts an empty data-juneau-calendar marker and juneau-calendar.js hydrates it. The
server paints the initial month and seed events (progressive enhancement); the runtime computes later months
client-side and fetches their events from a same-origin, data-only per-month GET.
var cal = CalendarDef.create()
.id("oncall")
.endpoint("servlet:/oncall/events/{year}/{month}")
.categories(
EventCategory.create().id("primary").label("Primary").color(EventCategory.CategoryColor.BLUE),
EventCategory.create().id("backup").label("Backup").color(EventCategory.CategoryColor.AMBER))
.events(
CalendarEvent.create().id("e1").title("Alice").start("2026-03-02").end("2026-03-04").categoryId("primary"),
CalendarEvent.create().id("e2").title("Deploy window").start("2026-03-05T09:00").end("2026-03-05T10:00"))
.weekStart(CalendarDef.WeekStart.MONDAY)
.maxPerDay(4);
| Property | Meaning |
|---|---|
id | Required; must match [A-Za-z][A-Za-z0-9_-]*. Emitted as data-juneau-calendar. |
endpoint | Same-origin path template containing both {year} and {month}. null makes the calendar seed-only and disables month navigation. |
categories | Legend entries in display order. A seed event's categoryId must be declared here. |
events | Optional initial-month seed events. |
view | CalendarView.MONTH is the only value (default). |
weekStart | SUNDAY (default) or MONDAY. |
initialYear / initialMonth | Set together with .initial(year, month), or neither (the server clock decides). Month is 1–12. |
maxPerDay | Chips per day before "+N more". Default 3, minimum 1. |
Spanning bars occupy lanes, with a budget equal to maxPerDay clamped to a hard ceiling of 8 per week row. The two
caps are separate: a bar crossing a cell does not consume that cell's chip budget.
Events
CalendarEvent has id, title, start, end, allDay, categoryId, href and tooltip. start/end are ISO-8601
local values: yyyy-MM-dd or yyyy-MM-ddTHH:mm. A trailing offset or Z is ignored rather than rejected, and the time
renders in the server's clock zone.
end is layout-significant, with split inclusivity:
| Shape | end is | Example |
|---|---|---|
| All-day (date-only) | inclusive | start=2026-03-02, end=2026-03-04 fills three day cells |
| Timed (date-time) | exclusive | 09:00 to 10:00 is [09:00, 10:00) on the start day; crossing midnight makes a spanning bar |
| Omitted | start-only, valid | an all-day event fills the start day; a timed one is a zero-width chip |
A timed event whose end equals its start, or any end before start, is malformed. So is an allDay/end
disagreement (for example allDay=true with a date-time end). A malformed seed event is dropped, not fatal: it
neither fails the definition nor the whole month payload, and a per-month GET handler should apply the same rule with
CalendarEvent.retainWellFormed(...).
Event titles, tooltips and labels are painted with textContent. href must be a same-origin document URL.
Categories
EventCategory has id, label, color and description. color is one of the fixed CategoryColor values
BLUE, GREEN, AMBER, RED and NEUTRAL (the default). There is no author-supplied inline color, so the emitted
class is built only from a sanitized id and a fixed token — no CSS-injection surface. An unknown categoryId on
a wire event paints as NEUTRAL.
"2"end became layout-significant, and the constant doubles as the data-juneau-calendar-contract handshake stamp, so a
v1 runtime cannot silently draw a multi-day event as one chip.
Badges
Badge is an overlay on a header action (a bell count) or inside a BarBadge. It is exactly one of a count badge or
a dot badge, never both.
Badge.count(7).max(99).tone(StatusTone.ERROR).label("unread notifications"); // "7"; 120 would show "99+"
Badge.dot().tone(StatusTone.INFO); // presence only
| Property | Rule |
|---|---|
count | >= 0. Mutually exclusive with dot. |
dot | Presence badge, no number. |
max | >= 1; a larger count renders as max+. |
tone | A StatusTone (INFO, SUCCESS, WARNING, ERROR, NEUTRAL); null keeps the default color. A tone outside that palette fails closed: nothing is painted. |
label | Visually hidden screen-reader context. |
Counts are painted with textContent. Badge is a plain bean, not a Widget; its validate() is called by the
enclosing bean.
Bar slots: BarSlot
BarSlot is an additive trailing region beside the subtab ribbon — a small row of BarWidgets such as "3 changes
pending". It is a Widget with id, widgets (at least one, ids unique) and an optional same-origin refreshUrl for
demand-refreshing the counts. BarWidget is sealed over two types:
BarBadge.of(id).label("change pending").badge(Badge.count(3).tone(StatusTone.WARNING))BarText.of(id, "Last synced 10:42")
var slot = BarSlot.create("pending")
.widgets(BarBadge.of("chg").label("changes pending").badge(Badge.count(3).tone(StatusTone.WARNING)),
BarText.of("env", "prod"))
.refreshUrl("/pending/count");
A BarSlot can be attached to a ModalDef (barSlot(...), anchored right after the title) and, in the views module, to a
page and to a Detail View. On the modal it is carried on the wire, because the modal is itself the wire payload.
Actions and menus
| Type | Role |
|---|---|
ActionBarItem | Marker for one item in an action bar: an ActionRef or a SafeAction. |
ActionRef | An opaque id naming a write action on the enclosing view. The action's existence is checked in the views module, not here. |
SafeAction | A client-only action with no endpoint. The one constant is COLLAPSE (collapse the child row), which stays enabled even during an in-flight or failed expand so the user can dismiss it. |
Behavior | How a header or menu action acts when activated: LINK (same-origin href), SAFE (dispatch a format-validated client event token that the host app interprets) or MENU. |
MenuItem | One row of a header or avatar menu: a link (MenuItem.link(id, label, href)), a safe item (MenuItem.safe(id, label, token)) or a divider (MenuItem.divider()). |
Behavior.MENU is not functional yetThe menu manager has not shipped. Until it does, a MENU trigger is disabled and its list is omitted.
MenuItem.validate() requires exactly one of href/safe on a non-divider, a same-origin href, and a safe token
matching ^[a-z][a-z0-9-]{0,63}$. Dividers are exempt from every id/label/target rule.
Gating an action on row state: ActionRef.enabledWhen
ActionRef.of("resolve")
.emphasis(ActionRef.Emphasis.PRIMARY)
.enabledWhen("status", Op.EQ, "open", "Only open incidents can be resolved.")
.enabledWhen("owner", Op.PRESENT, "Assign an owner first.");
Op is the shared single-field comparison vocabulary: EQ and NE take a value, PRESENT and ABSENT do not
(Op.requiresValue() discriminates; the wrong overload throws). The rules obey a few deliberate constraints:
- Presentation only, never authorization. The server re-reads the row and refuses on its own authority.
- Prefer disabled; hide only when irrelevant. An action that is merely impossible right now stays visible and disabled with a reason. A detail-panel action has no
visibleWhen; to drop an action from a whole view, omit it from that view's list. (Row actions in a table view can also declarevisibleWhenfor the case where the action does not apply to a row at all.) - The first-declared failing rule wins, and its
reasonis shown via a tooltip andaria-describedby. Declaration order is the priority mechanism. - Fail closed. A rule on a field the panel never declares is rejected at startup; if the field is missing from a row at runtime the rule fails and the action disables (even
ABSENT).
ActionRef.Emphasis is PRIMARY (solid accent; at most one per bar) or SECONDARY (the default). Both emphasis and
enabledWhen are Java-only and never marshalled.
Server values: ServerValues
ServerValues declares named scalar providers and interpolates them into widget chrome text (titles, labels) as
$FV{name} or $FV{name,default} at serve time. It is a thin typed facade over the
SVL engine, not a second templating language, and it is Java-only
(lambda providers are never serialized).
ServerValues sv = ServerValues.create()
.value("failedCount", session -> metrics.failed())
.value("env", session -> "prod");
// Column title "Failures ($FV{failedCount})" resolves at serve time.
- A provider returns a
String,Number,Booleanornull. Anything else is rejected at resolve time; collections are not joined. - A name that is not declared, or a
nullresult, falls through to the$FV{name,default}default. - A provider that throws propagates (fail-closed).
- Providers are session-aware (
Function<VarResolverSession,?>) and re-run on every reference unless the value is declared cacheable throughServerValuesValue.of(name, provider, true). Cache only values that are stable across a whole response.
$FV is not $W: $W emits raw widget HTML, while $FV resolves plain, serializer-encoded text and never injects
markup. The $FV variable itself (ServerValuesVar) lives in juneau-console-views.
WidgetsMixin — serving the runtime assets
Compose the mixin to serve the three first-party assets:
@Rest(mixins=WidgetsMixin.class)
public class MyRest extends BasicRestServlet { ... }
| Path constant | Served at | Purpose |
|---|---|---|
CALENDAR_JS_PATH | /juneau-calendar.js | Calendar runtime |
CALENDAR_CSS_PATH | /juneau-calendar.css | Calendar stylesheet (declares --jc-* token fallbacks) |
Assets are cacheable for one day and carry a content-sensitive cache-buster. Reference them from a page with
WidgetsMixin.widgetAssetUrl(path) (a servlet:-prefixed URL that Juneau's own HTML serializer resolves) or, for a
template engine that never sees that rewrite, WidgetsMixin.widgetAssetUrl(req, path) (an absolute, browser-fetchable
URL).
juneau-calendar.js must come after the views module's juneau-views.js. The calendar defines no stack of its own and fails loudly if the shared one is absent.
The mixin also exposes the handshake stamps the runtimes bake in: CALENDAR_CONTRACT_VERSION ("2", alias of
CalendarDef), BAR_CONTRACT_VERSION ("1", alias of BarSlot) and HEADER_CONTRACT_VERSION ("1"). They are
deliberately independent, so a calendar-envelope change never forces a bar or header bump.
The views module keeps deprecated accessors at the paths it served these assets from before they moved; they read the same bytes from this module, so existing applications keep working.
The serving-path validation gate
WidgetsMixin.WidgetValidationProcessor is a ResponseProcessor that calls validate() on any REST response whose
content is a Widget, and answers a failure with HTTP 500 (a malformed widget is a server defect, not a client
error). It returns NEXT, so it never claims the response or perturbs the chain.
It is registered twice, on purpose: through ServiceLoader (so it applies to every resource on a classpath that carries
the module, with no opt-in) and through the mixin's own @Rest(responseProcessors=...) (so a host that composes the
mixin is covered even if a shading step drops the services file).
Things to know:
- Top level only. It tests
getContent()itself. AWidgetnested in aListorMapis not walked; nested widgets inside aModalDefare covered by that bean's own recursivevalidate(). - Non-REST producers are not gated. A direct
Json.of(modal)has no processor in front of it. Callvalidate()or the chainingModalDef.checked()/FormDef.checked()yourself. - Double validation is intentional. A modal that already called
checked()is validated again by the processor;validate()is cheap and idempotent. - Escape hatch. To serve a deliberately invalid widget, replace the whole chain with a
@Bean ResponseProcessorListfactory method. There is no narrower opt-out.
See also
- 10.31.2 Widgets and 10.31.3 Predefined Widgets — the unrelated
$W{...}HTML header widgets - 10.64 Admin-Console Chrome — the themeable chrome these widgets are painted with
- 10.65 Markdown View Rendering
- org.apache.juneau.console.widgets