admin: pick a birthday from a calendar
The model holds six shapes, a date input holds one,
so the picker fills the boxes and adds no field of its own.
This app draws no calendar — the browser already had one —
and a no-year birthday still sheds that year alone.
Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
diff --git a/docs/plans/2026-09-15-a-picker-beside-the-birthday.md b/docs/plans/2026-09-15-a-picker-beside-the-birthday.md
new file mode 100644
index 0000000..718557b
--- /dev/null
+++ b/docs/plans/2026-09-15-a-picker-beside-the-birthday.md
@@ -0,0 +1,83 @@
+# A picker beside the birthday, not instead of it
+
+2026-09-15. The birthday row grows a calendar affordance. The three
+controls docs/plans/2026-09-07-web-birthday-editor.md put there stay
+exactly as they were; this adds a fourth thing to the row and changes
+nothing about what posts.
+
+## Why the picker cannot be the row
+
+`<input type="date">` holds one shape: a complete year, month, and
+day. The model holds six (RFC 6350 section 4.3.1, and `Birthday`'s
+constructor), and the birthday editor is deliberately the one screen
+where all six are reachable — the plan above argues that at length.
+Swapping the three controls for a date input would take four of those
+six off the screen, and a contact already holding one could not be
+opened for editing without losing it.
+
+So the date input is not a field. It is an affordance that writes the
+fields: the person clicks a calendar, picks a day, and the month,
+day, and year boxes fill in. They remain what the form submits, the
+save is untouched, and the no-year birthday is still typed the way it
+always was — pick the date, clear the year.
+
+That last step is the trade this control makes and it is not hidden:
+a calendar cannot express "December 10, no year," so picking always
+writes three values and one of them may need deleting. The row's
+own boxes are the shorter path for that case, and they are still
+right there.
+
+## How it opens
+
+The button calls `showPicker()` on a date input the row keeps but
+never shows. There is no calendar drawn here — no month grid, no
+Alpine component, no third-party widget. The browser's own picker is
+better at this than anything this app would build, and it carries its
+keyboard handling and its localization with it.
+
+The input is hidden by being taken out of flow at zero width
+(admin.css), not by `display: none`. `showPicker()` throws
+`InvalidStateError` on an element that is not mutable, and what the
+standard promises about an element that is not rendered at all is not
+something worth resting on — so the input stays rendered and simply
+occupies no space. It carries no `name`, so nothing of it reaches the
+save, and `tabindex="-1"` with `aria-hidden` keeps it out of both the
+tab order and the accessibility tree: the button is the control a
+person meets.
+
+Opening syncs the input from the three boxes rather than prefilling
+it once at render, so a date typed into the boxes is where the
+calendar opens. A partial birthday assembles a string no date input
+accepts, and the value sanitization algorithm for the date state
+(HTML section 4.10.5.1.7) leaves the input empty rather than
+erroring — which is the behavior wanted, and why no guard stands
+there.
+
+## The row now holds two buttons
+
+The picker and the remove control are both `.icon-button`s in the
+`.date-row`, and the narrow shapes stack them in the column the
+single button used to have to itself. They are told apart by order
+rather than by a class or a data attribute: the picker renders in
+both the standing row and the one the add dialog reveals, the remove
+only in the standing row, so the picker is the first button either
+way and the remove, where it exists, is the second. Only the second
+takes the danger color on hover — the picker is not destructive and
+keeps the quiet register.
+
+Two selectors in admin.css shed a bare `input` for
+`input[type="number"]` along the way, and both were latent bugs
+waiting for a second kind of input in this row: `:last-of-type`
+counts by tag and would have named the date input instead of the
+year box, and the strike-through rule's `:not(:has(input:not(
+:placeholder-shown)))` would have read the row as filled forever,
+a date input supporting no placeholder and so never matching
+`:placeholder-shown`.
+
+## Non-goals
+
+- A calendar this app draws. The native picker is the whole point.
+- Reaching the four unserved shapes through the picker. They are
+ typed, as they always were.
+- Any change to what posts, what the save parses, or what a client
+ receives. This is one button.
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 3a6caa6..9775962 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -315,7 +315,10 @@ module ProTacts
# class) carrying the Lucide x (Admin::Icon), its
# aria-label the name the glyph cannot show; the card
# UI's removal register — low-contrast at rest, danger
- # on hover — is admin.css's.
+ # on hover — is admin.css's. The picker's own date input
+ # is swept along by the bare `input` here, which is
+ # right: what the calendar had staged is no longer what
+ # the row says.
clear = "$el.closest('.date-row').querySelectorAll('select, input').forEach(el => el.value = '')"
button(type: "button", class: "icon-button", data_size: "sm",
aria_label: "Remove birthday", "@click": clear) do
@@ -325,9 +328,12 @@ module ProTacts
end
end
- # The birthday row's three controls, shared by the standing row
- # and the add dialog's — the control choices and their order are
- # docs/plans/2026-09-07-web-birthday-editor.md, "The row". The
+ # The birthday row's controls, shared by the standing row and
+ # the add dialog's — the three fields, their order, and why they
+ # are three rather than one date input are
+ # docs/plans/2026-09-07-web-birthday-editor.md, "The row", and
+ # the picker beside them is
+ # docs/plans/2026-09-15-a-picker-beside-the-birthday.md. The
# same names for both callers is the point: a birthday needs no
# digest, there being at most one, and the save reads either
# caller's row identically. A nil birthday is the added row.
@@ -344,6 +350,56 @@ module ProTacts
min: 1, max: 31, placeholder: "day", aria_label: "day")
input(type: "number", name: "birthday[year]", value: birthday&.year,
min: 1, max: 9999, placeholder: "year", aria_label: "year")
+ birthday_picker
+ end
+
+ # The calendar affordance: a date input the row never shows and
+ # never submits, and the button that opens its native picker.
+ # The three fields stay the value — a date input holds only the
+ # complete shape, and four of the six this row edits have no
+ # spelling in one — so the picker writes into them and they are
+ # what posts (docs/plans/2026-09-15-a-picker-beside-the-birthday.md).
+ #
+ # The input carries no `name`, so nothing of it reaches the
+ # save; `tabindex` and `aria-hidden` keep it out of both orders,
+ # the button being the control a person actually meets. It is
+ # hidden by being taken out of flow at zero width rather than by
+ # `display: none` (admin.css), because showPicker() on an
+ # element that is not rendered is not a thing the HTML standard
+ # promises to honor.
+ #
+ # aria-hidden is spelled out rather than left bare: ARIA's
+ # true/false attributes are enumerated, not HTML booleans, and
+ # a present-but-empty one is not the same claim as "true" in
+ # every reading of the spec. Icon writes the bare form and is
+ # harmless doing it — a decorative svg inside a labelled button
+ # contributes no name either way — but this is an input, and
+ # an input that reaches the tree unlabelled is not harmless.
+ #
+ # Opening syncs the picker to the fields rather than the fields
+ # being prefilled once at render: a date typed into the boxes is
+ # then where the calendar opens. A blank component makes a
+ # string no date input accepts, and the assignment of an invalid
+ # value leaves the input empty (HTML section 4.10.5.1.7) — so a
+ # partial birthday opens the picker on its own default with no
+ # guard here. Picking writes all three fields, which is the
+ # trade this control is: the year is the one a no-year birthday
+ # then blanks by hand.
+ #: () -> void
+ def birthday_picker
+ sync = "const r = $el.closest('.date-row'), n = r.querySelectorAll('input[type=number]'), " \
+ "p = r.querySelector('input[type=date]'); " \
+ "p.value = `${n[1].value.padStart(4, '0')}-${r.querySelector('select').value.padStart(2, '0')}" \
+ "-${n[0].value.padStart(2, '0')}`; p.showPicker()"
+ fill = "if (!$el.value) return; " \
+ "const r = $el.closest('.date-row'), n = r.querySelectorAll('input[type=number]'), " \
+ "[y, m, d] = $el.value.split('-'); " \
+ "r.querySelector('select').value = +m; n[0].value = +d; n[1].value = +y"
+ input(type: "date", tabindex: -1, aria_hidden: "true", "@change": fill)
+ button(type: "button", class: "icon-button", data_size: "sm",
+ aria_label: "Pick a birthday from a calendar", "@click": sync) do
+ render Icon.new(:calendar)
+ end
end
# An address row: the type's caption and a stack of component
diff --git a/lib/pro_tacts/admin/icons.rb b/lib/pro_tacts/admin/icons.rb
index fcb3ba9..effd330 100644
--- a/lib/pro_tacts/admin/icons.rb
+++ b/lib/pro_tacts/admin/icons.rb
@@ -10,17 +10,31 @@ module ProTacts
# cannot cross an <img>: the stroke has to inherit from the
# element that carries the glyph, Gloss's IconButton included. A
# new glyph lands the same way this one did — named in the
- # registry, its paths verbatim, this comment's version moved with
+ # registry, its shapes verbatim, this comment's version moved with
# it.
class Icon < Phlex::SVG
# @rbs @name: Symbol
# @rbs @size: Integer
- # One glyph's path data, verbatim from the lucide-static SVG's
- # own path elements — x, the remove control's two strokes.
- PATHS = {
- x: ["M18 6 6 18", "m6 6 12 12"],
- } #: Hash[Symbol, Array[String]]
+ # One glyph's shapes, each an element name and its attributes,
+ # verbatim from the lucide-static SVG's own children: x, the
+ # remove control's two strokes, and calendar, the birthday row's
+ # picker. A tag per shape rather than a bare list of path data,
+ # because calendar's frame is a rect and redrawing it as a path
+ # would be this file copying something other than what Lucide
+ # ships.
+ GLYPHS = {
+ x: [
+ [:path, {d: "M18 6 6 18"}],
+ [:path, {d: "m6 6 12 12"}],
+ ],
+ calendar: [
+ [:path, {d: "M8 2v4"}],
+ [:path, {d: "M16 2v4"}],
+ [:rect, {width: 18, height: 18, x: 3, y: 4, rx: 2}],
+ [:path, {d: "M3 10h18"}],
+ ],
+ } #: Hash[Symbol, Array[[Symbol, Hash[Symbol, untyped]]]]
#: (Symbol name, ?size: Integer) -> void
def initialize(name, size: 16)
@@ -35,8 +49,8 @@ module ProTacts
svg(viewBox: "0 0 24 24", width: @size, height: @size, fill: :none,
stroke: "currentColor", stroke_width: 2, stroke_linecap: "round",
stroke_linejoin: "round", aria_hidden: true) do
- PATHS.fetch(@name).each do
- path(d: it)
+ GLYPHS.fetch(@name).each do |tag, attributes|
+ public_send(tag, **attributes)
end
end
end
diff --git a/public/admin.css b/public/admin.css
index f00c959..9230adc 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -141,38 +141,63 @@
.card-body form.field-stack .field > .date-row {
display: flex;
gap: var(--gl-space-2xs);
+ position: relative;
}
.card-body form.field-stack .field > .date-row > select {
flex: 1 1 auto;
padding-block: var(--gl-space-3xs);
}
-.card-body form.field-stack .field > .date-row > input {
+/* The two digit boxes by their type, not by being inputs: the row
+ also holds the picker's date input, which is not a box anyone
+ sizes (below). */
+.card-body form.field-stack .field > .date-row > input[type="number"] {
flex: 1 1 auto;
width: calc(7ch + 2 * var(--gl-space-s) + 2px);
padding-block: var(--gl-space-3xs);
}
-/* The birthday row's remove control (Admin::ContactsEdit): Gloss's
+/* The picker's date input (Admin::ContactsEdit#birthday_picker),
+ which the row never shows: out of flow so it takes no flex item
+ and no grid cell in either of the narrow shapes below, at zero
+ width in the corner the native picker should hang from. Not
+ `display: none` — showPicker() is only promised on an element
+ that is rendered — and not `visibility: hidden` either, which
+ the same doubt covers. */
+.card-body form.field-stack .field > .date-row > input[type="date"] {
+ position: absolute;
+ inset-block-end: 0;
+ inset-inline-end: 0;
+ width: 0;
+ padding: 0;
+ border: 0;
+ opacity: 0;
+ pointer-events: none;
+}
+/* The birthday row's two icon controls (Admin::ContactsEdit): Gloss's
IconButton is already square, flex-none, and quiet — the one thing
- this layer adds is centering it on the date line, whose children
+ this layer adds is centering them on the date line, whose children
stretch, and the removal register the card UI claims for itself
(docs/DESIGN.md): danger only on hover, the icon's stroke
- inheriting it. */
+ inheriting it. The hover color is the remove control's alone; the
+ picker is not destructive and keeps the quiet register. Their order
+ is their identity — the picker renders in both the standing row and
+ the added one, the remove only in the standing row, so the picker
+ is the first button either way. */
.card-body form.field-stack .field > .date-row > .icon-button {
align-self: center;
}
-.card-body form.field-stack .field > .date-row > .icon-button:hover {
+.card-body form.field-stack .field > .date-row > button:nth-of-type(2):hover {
color: var(--gl-color-danger);
}
/* The date line breaks in two when its row cannot hold a whole
- date: a month name, two year boxes, and the remove button need
+ date: a month name, two year boxes, and the two icon buttons need
~20rem side by side, and on a phone the value column has a
fraction of that — three controls squeezed into it rendered each
- a stub. The narrow shape is two lines with the button in a
- column of its own: the month over the day and year, the month
+ a stub. The narrow shape is two lines with the buttons in a
+ column of their own: the month over the day and year, the month
name being the wide value and the two digit boxes pairing, and
- the button beside the month, where desktop's line has it. The
- digit boxes shed their desktop width to fill their half-tracks,
+ the buttons stacked beside them, where desktop's line has them.
+ The digit boxes shed their desktop width to fill their half-tracks,
the address row's stacked fields the same reading. A second
break, at 21rem, gives each field its own line — the digit pair
wants its desktop width a box, and a half-track that cannot
@@ -201,16 +226,25 @@
.card-body form.field-stack .field > .date-row > select {
grid-area: 1 / 1 / 2 / 3;
}
- .card-body form.field-stack .field > .date-row > input {
+ .card-body form.field-stack .field > .date-row > input[type="number"] {
grid-area: 2 / 1;
width: auto;
}
- .card-body form.field-stack .field > .date-row > input:last-of-type {
+ /* The year box by the box it follows, not by :last-of-type — the
+ row's last input child is the picker's date input, which shares
+ the tag that selector counts. */
+ .card-body form.field-stack .field > .date-row > input[type="number"] + input[type="number"] {
grid-area: 2 / 2;
}
- .card-body form.field-stack .field > .date-row > .icon-button {
+ /* The picker beside the month, the remove beside the digits — each
+ button on the line whose value it acts on, and neither of them
+ auto-placed into a track a field wants. */
+ .card-body form.field-stack .field > .date-row > button:nth-of-type(1) {
grid-area: 1 / 3;
}
+ .card-body form.field-stack .field > .date-row > button:nth-of-type(2) {
+ grid-area: 2 / 3;
+ }
}
/* The second break: two columns — field and button — one row per
field. Every grid-area restates the first block's own, because a
@@ -223,15 +257,18 @@
.card-body form.field-stack .field > .date-row > select {
grid-area: 1 / 1;
}
- .card-body form.field-stack .field > .date-row > input {
+ .card-body form.field-stack .field > .date-row > input[type="number"] {
grid-area: 2 / 1;
}
- .card-body form.field-stack .field > .date-row > input:last-of-type {
+ .card-body form.field-stack .field > .date-row > input[type="number"] + input[type="number"] {
grid-area: 3 / 1;
}
- .card-body form.field-stack .field > .date-row > .icon-button {
+ .card-body form.field-stack .field > .date-row > button:nth-of-type(1) {
grid-area: 1 / 2;
}
+ .card-body form.field-stack .field > .date-row > button:nth-of-type(2) {
+ grid-area: 2 / 2;
+ }
}
/* A multiline control has no single line to align a caption to, and
what a browser calls its baseline is not settled — a scroll
@@ -262,7 +299,10 @@
each selector naming its row's own removal condition — an
address only blank throughout its components, a partial blank
keeping the line, and the birthday only with the month's own
- empty option picked, both the save's rules. The boxes keep
+ empty option picked, both the save's rules. The birthday's asks
+ after its digit boxes by type: the picker's date input supports
+ no placeholder, so :placeholder-shown never matches it and a
+ bare `input` there would read the row as filled forever. The boxes keep
their neutral borders: Gloss's danger border is the
aria-invalid contract, and a blank row is a valid save, not a
refused one. One gap, noted rather than scripted: a
@@ -271,7 +311,7 @@
.field[data-blank-removes]:has(> input:placeholder-shown) > span,
.field[data-blank-removes]:has(> textarea:placeholder-shown) > span,
.field[data-blank-removes]:has(> .field-stack):not(:has(input:not(:placeholder-shown))) > span,
-.field[data-blank-removes]:has(> .date-row):has(option[value=""]:checked):not(:has(input:not(:placeholder-shown))) > span {
+.field[data-blank-removes]:has(> .date-row):has(option[value=""]:checked):not(:has(input[type="number"]:not(:placeholder-shown))) > span {
text-decoration: line-through;
color: var(--gl-color-danger);
}
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 19c8273..153da97 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -35,6 +35,30 @@ class AdminContactsPagesTest < Minitest::Test
"NOTE:Gate code 1854.",
].freeze #: Array[String]
+ # The birthday row's calendar affordance
+ # (docs/plans/2026-09-15-a-picker-beside-the-birthday.md): a date
+ # input the row never shows and never submits, and the button that
+ # opens its native picker. Byte-identical in the standing row and
+ # the one the add dialog reveals — the picker prefills from the
+ # three boxes rather than from anything rendered, so there is
+ # nothing for the two callers to differ about — which is why the
+ # two tests over it share this rather than each spelling it out.
+ BIRTHDAY_PICKER =
+ %(<input type="date" tabindex="-1" aria-hidden="true" @change="if (!$el.value) return; ) +
+ %(const r = $el.closest('.date-row'), n = r.querySelectorAll('input[type=number]'), ) +
+ %([y, m, d] = $el.value.split('-'); ) +
+ %(r.querySelector('select').value = +m; n[0].value = +d; n[1].value = +y">) +
+ %(<button type="button" class="icon-button" data-size="sm" ) +
+ %(aria-label="Pick a birthday from a calendar" ) +
+ %(@click="const r = $el.closest('.date-row'), n = r.querySelectorAll('input[type=number]'), ) +
+ %(p = r.querySelector('input[type=date]'); ) +
+ %(p.value = `${n[1].value.padStart(4, '0')}-${r.querySelector('select').value.padStart(2, '0')}) +
+ %(-${n[0].value.padStart(2, '0')}`; p.showPicker()">) +
+ %(<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" ) +
+ %(stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden>) +
+ %(<path d="M8 2v4"></path><path d="M16 2v4"></path>) +
+ %(<rect width="18" height="18" x="3" y="4" rx="2"></rect><path d="M3 10h18"></path></svg></button>) #: String
+
def test_index_lists_recently_updated_contacts
with_contacts({"ada" => ADA}) do
get "/"
@@ -1022,7 +1046,12 @@ class AdminContactsPagesTest < Minitest::Test
refute_includes body, '<span>birthday</span>'
assert_includes body,
'<label x-show="!added.includes(\'birthday\')"><input type="radio" name="add-type" value="birthday" x-model="type">birthday</label>'
- refute_includes body, "icon-button"
+ # The remove control by its own label, not by the class it
+ # shares: the add dialog's birthday template carries a picker
+ # wearing the same one, inert inside its <template> but present
+ # in the bytes, so "icon-button" stopped meaning "a control
+ # that deletes something."
+ refute_includes body, 'aria-label="Remove birthday"'
end
end
@@ -1519,7 +1548,10 @@ class AdminContactsPagesTest < Minitest::Test
# prefill is Contact#birthday, three controls on one line — a month
# select (names, constrained by construction) and ranged number
# inputs — in the value column, month-day-year matching the prose
- # the details page renders.
+ # the details page renders. The picker follows them and is not a
+ # fourth field: it carries no name, so the three boxes are still
+ # the whole of what a save reads
+ # (docs/plans/2026-09-15-a-picker-beside-the-birthday.md).
def test_the_edit_screen_renders_the_birthday_row
with_contacts({"ada" => ADA}) do
get "/contacts/ada/edit"
@@ -1532,7 +1564,8 @@ class AdminContactsPagesTest < Minitest::Test
assert_includes body,
'<input type="number" name="birthday[day]" value="10" min="1" max="31" placeholder="day" aria-label="day">'
assert_includes body,
- '<input type="number" name="birthday[year]" value="1985" min="1" max="9999" placeholder="year" aria-label="year">'
+ '<input type="number" name="birthday[year]" value="1985" min="1" max="9999" placeholder="year" aria-label="year">' +
+ BIRTHDAY_PICKER
# The remove control: one click that speaks the three-blank
# rule, over a held birthday only — Gloss's IconButton, so the
# glyph carries the label.
@@ -1765,19 +1798,22 @@ class AdminContactsPagesTest < Minitest::Test
'<input type="text" :name="`new_address[${i}][postal_code]`" placeholder="postal code" aria-label="postal code">' \
'<input type="text" :name="`new_address[${i}][country]`" placeholder="country" aria-label="country">' \
'</div></template>'
- # The birthday kind: the standing row's own three controls with
+ # The birthday kind: the standing row's own controls with
# nothing prefilled, naming the model's birthday[] group — no
# digest, a contact holding at most one, and no removal state,
- # the added rows' exemption. Asserted in two contiguous pieces:
- # the month names sit between the empty option and December.
+ # the added rows' exemption. The picker comes along, an added
+ # birthday being as pickable as a standing one; the remove
+ # control does not, there being nothing yet to remove.
+ # Asserted in two contiguous pieces: the month names sit
+ # between the empty option and December.
assert_includes body,
'<template x-if="kind === \'birthday\'"><div class="date-row">' \
'<select name="birthday[month]" aria-label="month" x-init="$el.focus()"><option value="" selected>month</option>'
assert_includes body,
'<option value="12">December</option></select>' \
'<input type="number" name="birthday[day]" min="1" max="31" placeholder="day" aria-label="day">' \
- '<input type="number" name="birthday[year]" min="1" max="9999" placeholder="year" aria-label="year">' \
- '</div></template></div></template>'
+ '<input type="number" name="birthday[year]" min="1" max="9999" placeholder="year" aria-label="year">' +
+ BIRTHDAY_PICKER + "</div></template></div></template>"
# The template is inside the edit form, so one Save writes every
# row it made along with everything else. Indexed from the
# form's start, the header's search form closing before both.