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>
change tpnxpkxqunouktrvtvqrsontrpovyzoz
commit 766a11aff9e2d3dc1c5c397c92936bbd26e3a8d0
author Alpha Chen <alpha@kejadlen.dev>
date
parent lvvzolmy
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.