Put the otp input after its boxes

A seventh box appeared when the field took focus.

daisyUI positions each otp box with nth-child, which counts *every* child of the
container, not just the spans. With the input as the first child, all six boxes
shifted one stride right and the container matched :has(>span:nth-child(7)), so
it grew to seven strides wide. The phantom box was the ::after active-box marker
-- transparent until :focus-within gives it an outline -- sitting in the empty
stride that the off-by-one had opened up.

The input goes last, which is also how daisyUI's own examples order it. A test
counts the boxes ahead of the input so this cannot come back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 23:16:35 +02:00
parent fa213d95ee
commit add1ee87ae
2 changed files with 24 additions and 9 deletions

View File

@@ -265,6 +265,15 @@ class TwoFactorPageTests(TestCase):
self.assertContains(self.response, 'name="code"')
self.assertContains(self.response, "otp otp-lg")
def test_the_input_comes_after_the_boxes(self):
# daisyUI places each box with nth-child, which counts every child. With the input
# first, all six boxes shift a stride right, the container grows to seven strides
# and the ::after focus marker appears as a phantom seventh box.
html = self.response.content.decode()
otp = html[html.index('class="otp otp-lg"') : html.index('name="code"')]
self.assertEqual(otp.count("<span></span>"), 6)
def test_the_otp_field_has_no_placeholder(self):
# allauth sets placeholder="Code"; inside the boxes it reads as a typed-in code.
self.assertNotContains(self.response, 'placeholder="Code"')

View File

@@ -43,20 +43,26 @@
{% with icon=field|field_icon %}
{% if field|is_otp %}
{% comment %}
daisyUI's otp is one input overlaid on the boxes, and it sizes itself from
its `span` children — so the sr-only label goes OUTSIDE the container, or it
would count as a seventh box and throw the width off.
daisyUI's otp is one input overlaid on the boxes. Child order is load-bearing:
it places each box with `nth-child`, which counts *every* child, not just the
spans — so the input must come LAST. Put it first and all six boxes shift one
stride right, the container matches :has(>span:nth-child(7)) and grows to
seven strides, and the ::after active-box marker (invisible until focus) shows
up in the gap as a phantom seventh box.
For the same reason the sr-only label lives outside the container: another
child would throw the count off again.
{% endcomment %}
{% if attrs.unlabeled %}<label class="sr-only" for="{{ field.id_for_label }}">{{ field.label }}</label>{% endif %}
<div class="flex justify-center">
<div class="otp otp-lg" data-otp>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
{{ field|as_otp }}
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
{% elif icon %}