Skip to content

feat(template): support xsd:gYear and its relatives as placeholder types - #674

Open
ashleycaselli wants to merge 4 commits into
masterfrom
feat/670-gregorian-placeholder-types
Open

feat(template): support xsd:gYear and its relatives as placeholder types#674
ashleycaselli wants to merge 4 commits into
masterfrom
feat/670-gregorian-placeholder-types

Conversation

@ashleycaselli

@ashleycaselli ashleycaselli commented Sep 2, 2026

Copy link
Copy Markdown
Member

Closes #670.

A literal placeholder typed xsd:gYear got a plain text field — nothing offered a year, nothing checked one, and "next spring" typed into it was published as "next spring"^^xsd:gYear. The same held for gYearMonth, gMonth, gMonthDay and gDay; only xsd:date and xsd:dateTime had components of their own, with a // TODO add all date time types where the rest belonged.

Why not a date picker

These types name a point on the calendar that is coarser than a date. "2026" and "--05-17" are precisely the values a full date has no way to leave unsaid, so a picker cannot stand in for them. Instead each part gets a control that can only produce something the datatype allows:

Datatype Controls Value
xsd:gYear year 2026
xsd:gYearMonth year + month 2026-05
xsd:gMonth month --05
xsd:gMonthDay month + day --05-17
xsd:gDay day ---17

The year is a number field (spinner, numeric keypad, and a pattern that decides where the input type is ignored); months and days are dropdowns, named rather than numbered.

What it refuses to publish

As much of the point as what it offers, since a nanopublication cannot be edited afterwards:

  • A half-entered value is reported, not published. A gYearMonth with a year and no month assembles to nothing and the form says "Please also select a month", rather than emitting "2026-"^^xsd:gYearMonth.
  • A day its month cannot have is reported: February 30 says so by name instead of becoming a gMonthDay no calendar has.
  • A timezone on a value filled in from an existing nanopublication is carried back out again. Nothing here offers to add one, but dropping one on re-publication would change what the nanopub says.

The value reaches the rest of the form as the single lexical string it always was, so TemplateContext needed no change — it already types the literal from the placeholder's datatype (#564). Keeping the parts behind that string is what lets "May" stay on screen in the moment before a day is picked.

Tests

18 new, 1250 in the suite, all green:

  • the lexical forms themselves — what each datatype assembles, what it refuses to assemble, what splits back into the same parts, negative years and five-digit years, and the timezone round-trip;
  • the form a user actually gets, through WicketTester: each datatype filled in and submitted, the partial and impossible values reported, a param_ URL seeding the fields, and an untouched optional field staying empty.

Not covered here: xsd:time and xsd:duration, which are clock and interval rather than calendar types and want a different control.

🤖 Generated with Claude Code

https://claude.ai/code/session_018J1JEopfv4icibN6bGkZ1d


Update — a bug found in review, fixed in the second commit. A rejected year left the field deaf: every correction was read, stored, and wiped again inside the same request, so the value stayed empty however often it was retyped.

The field carried two updating behaviors — its own, and the KeepValueAfterRefreshBehavior every other item adds on top of one. Both are OnChangeAjaxBehavior, so both run per change, and the second reaches updateModel() when the input it reads is already empty, writing that emptiness over what the first had just stored. A plain text field never shows it: its model takes the same value twice without harm. A value assembled from parts loses the part just set.

The field now keeps one updating behavior — the Ajax round-trip it already makes is what kept values across a refresh, which is all the second was for — and it finds the components to refresh by the value behind the model rather than by the model itself, so a placeholder used in more than one statement stays in step.

Four tests came with it, all driving the Ajax path rather than a form submit, which is where this only ever showed: a corrected year after a rejected one, both parts of a gMonthDay picked one at a time, and a value surviving a change to another field.

Update 2 — the field was still unusable under a template pattern, fixed in the third commit. Entering 46464 where the template's pattern is [0-9]{4} reported Value '46464' doesn't match the pattern for every value entered afterwards, the field refusing corrections until the page was left behind.

The pattern was applied to the stored value rather than to the entry being made. Nothing had judged the first entry, so it was stored; from then on it was the value every check measured, and no correction could replace it, since a failed check is exactly what stops the entry being stored. Checks now assemble the value from what the controls hold.

Three faults of the same kind came out with it, each with a test:

  • A value-wide check lived on one part. A pattern that only the month breaks went unapplied when the month was what changed. The template pattern and the impossible-date check now sit on every part, with only the first to report keeping its message.
  • Requiring the other part on every change left both parts unstored. Each was rejected for the absence of the one the user had not reached yet, so a gYearMonth could never be completed at all. A part now requires the other only in a request that carries it — a submit — which is how a language-tagged literal requires its tag in LiteralTextfieldItem.
  • The day dropdown submitted the position of the day rather than the day. A reader of the field saw the 29th where the 30th was picked, and February 30 passed the check meant to catch it.

Suite: 1257 tests, green.

Update 3 — the year spinner starts at the current year. Stepping an empty number input lands on 1 or -1, a year nobody means and four keystrokes from any they do. The first press of either arrow on an empty year field now fills in the current year; from there the browser's own stepping takes over, counting up and down from wherever the value stands, and a field that already holds a year is left alone.

Typing carries an inputType and using the spinner does not, which is what tells a click on the arrows apart from a 1 the user meant to type. The filled-in year is dispatched as input and change, so it reaches the form rather than only the field.

ashleycaselli and others added 4 commits September 2, 2026 13:14
A literal placeholder typed xsd:gYear got a plain text field: nothing
offered a year, nothing checked one, and "next spring" typed into it was
published as "next spring"^^xsd:gYear. The same held for gYearMonth,
gMonth, gMonthDay and gDay. Only xsd:date and xsd:dateTime had components
of their own, with a TODO where the rest belonged.

These types name a point on the calendar that is coarser than a date,
which is why a date picker cannot stand in for them: "2026" and "--05-17"
are exactly the values a full date has no way to leave unsaid. So each
part gets a control that can only produce something the datatype allows —
a number field for the year, dropdowns naming the months and the days —
and the parts are assembled into the lexical form the datatype
prescribes.

What that rules out is as much of the point as what it offers. A value is
assembled only once every part it needs is there, so a half-entered
gYearMonth is reported rather than published as "2026-"; February 30 is
reported rather than published as a gMonthDay no calendar has; and a
timezone on a value filled in from an existing nanopublication is carried
back out again rather than silently dropped.

Closes #670

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018J1JEopfv4icibN6bGkZ1d
A year that failed validation left the field deaf: every later correction
was read, stored, and then wiped again within the same request, so the
value stayed empty however often it was retyped.

The field carried two updating behaviors: its own, and the
KeepValueAfterRefreshBehavior that every other item adds on top of one.
Both are OnChangeAjaxBehavior, so both run per change; the second reaches
updateModel() when the input it reads is already empty and writes that
emptiness over the value the first had just stored. Nothing showed for a
plain text field, whose model takes the same value twice without harm, but
a value assembled from parts loses the part that was just set.

So the field keeps one updating behavior. The Ajax round-trip it makes is
what kept values across a refresh, which is all the second one was for,
and the part it refreshes is now found by the value behind the model
rather than by the model itself, so a placeholder used in more than one
statement stays in step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018J1JEopfv4icibN6bGkZ1d
A template pattern on a gYear placeholder made the field unusable: enter
46464 against a pattern of [0-9]{4} and every later value was reported as
"Value '46464' doesn't match the pattern", the field refusing corrections
until the page was left behind.

The pattern was applied to the stored value rather than to the entry being
made. Nothing had judged the first entry, so it was stored; from then on it
was the value every check measured, and no correction could replace it,
since a failed check is exactly what stops the entry being stored. Checks
now assemble the value from what the controls hold, so what is judged is
what the user just typed.

Three faults of the same kind came out with it:

- A value-wide check lived on one part, so a pattern only the month broke
  went unapplied when the month was what changed. Both such checks -- the
  template pattern and the impossible-date one -- now sit on every part,
  and only the first to report keeps its message.
- Requiring the other part of a two-part value on every change left both
  parts unstored: each was rejected for the absence of the one the user had
  not reached yet, so the value could never be completed at all. A part now
  requires the other only in a request that carries it, which is a submit,
  the way a language-tagged literal requires its tag.
- The day dropdown submitted the position of the day rather than the day,
  so a reader of the field saw the 29th where the 30th was picked, and
  February 30 passed the check meant to catch it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018J1JEopfv4icibN6bGkZ1d
Stepping an empty number input lands on 1 or -1, a year nobody means and
four keystrokes away from any they do. The first press of either arrow on
an empty year field now fills in the current year instead; from there the
browser's own stepping takes over, so the arrows go on counting up and
down from wherever the value stands, and a field that already holds a year
is left alone entirely.

Typing carries an inputType and using the spinner does not, which is what
tells a click on the arrows apart from a 1 the user meant to type. The
filled-in year is dispatched as an input and a change event, so it reaches
the form rather than only the field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018J1JEopfv4icibN6bGkZ1d
@ashleycaselli
ashleycaselli requested a review from tkuhn September 2, 2026 14:32

@tkuhn tkuhn left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice! Only a few minor things:

  • "Update 3 — the year spinner starts at the current year": this didn't seem to work for me
  • The dropdown text "Choose One" should rather be "choose month" and "choose day".
  • And these month/day dropdowns could be narrower, and are currently slightly less tall than the other dropdowns and text field (just a very minor CSS issue).

Otherwise good to go!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support xsd:gYear etc. as placeholder types

2 participants