feat(template): support xsd:gYear and its relatives as placeholder types - #674
Open
ashleycaselli wants to merge 4 commits into
Open
feat(template): support xsd:gYear and its relatives as placeholder types#674ashleycaselli wants to merge 4 commits into
ashleycaselli wants to merge 4 commits into
Conversation
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
tkuhn
requested changes
Sep 3, 2026
tkuhn
left a comment
Contributor
There was a problem hiding this comment.
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!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #670.
A literal placeholder typed
xsd:gYeargot 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 forgYearMonth,gMonth,gMonthDayandgDay; onlyxsd:dateandxsd:dateTimehad components of their own, with a// TODO add all date time typeswhere 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:
xsd:gYear2026xsd:gYearMonth2026-05xsd:gMonth--05xsd:gMonthDay--05-17xsd:gDay---17The 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:
gYearMonthwith a year and no month assembles to nothing and the form says "Please also select a month", rather than emitting"2026-"^^xsd:gYearMonth.gMonthDayno calendar has.The value reaches the rest of the form as the single lexical string it always was, so
TemplateContextneeded 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:
WicketTester: each datatype filled in and submitted, the partial and impossible values reported, aparam_URL seeding the fields, and an untouched optional field staying empty.Not covered here:
xsd:timeandxsd: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
KeepValueAfterRefreshBehaviorevery other item adds on top of one. Both areOnChangeAjaxBehavior, so both run per change, and the second reachesupdateModel()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
gMonthDaypicked 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
46464where the template's pattern is[0-9]{4}reportedValue '46464' doesn't match the patternfor 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:
gYearMonthcould 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 inLiteralTextfieldItem.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
inputTypeand using the spinner does not, which is what tells a click on the arrows apart from a1the user meant to type. The filled-in year is dispatched asinputandchange, so it reaches the form rather than only the field.