Put icons in the login fields and drop their labels
The entrance forms lose their visible field labels and gain an icon inside each field (mail, lock), and allauth's "Forgot your password?" link -- which is the password field's help_text -- is spaced away from the input instead of sitting flush against it. Three things this depends on: - allauth already passes `unlabeled=True` on the entrance forms and already sets a placeholder on every field there, so the visible label was redundant. The label is still emitted sr-only: a placeholder is not a label, and it vanishes as soon as you type. - daisyUI's icon-in-field layout puts the `input` class on the *wrapping label*, so the input itself must carry only `grow` -- hence the optional css override on the daisy filter. `input` on both draws a box inside a box, and the error state belongs on the wrapper for the same reason. - The help text now carries id="<auto_id>_helptext". Django points the input's aria-describedby at exactly that id, so without it the reference dangled and a screen reader never announced the password-reset link. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -46,16 +46,40 @@ def as_alert(message):
|
||||
return {"icon": icon, "title": message.extra_tags or title, "body": message.message, "css": css}
|
||||
|
||||
|
||||
@register.filter
|
||||
def daisy(field):
|
||||
"""Render a bound form field with the right daisyUI classes."""
|
||||
widget = field.field.widget
|
||||
css = next((css for widget_type, css in WIDGET_CLASSES if isinstance(widget, widget_type)), DEFAULT_WIDGET_CLASS)
|
||||
#: Icon shown inside the field, by form field name. Anything unlisted gets none.
|
||||
FIELD_ICONS = {
|
||||
"login": "mail",
|
||||
"email": "mail",
|
||||
"email2": "mail",
|
||||
"oldpassword": "lock-keyhole",
|
||||
"password": "lock-keyhole",
|
||||
"password1": "lock-keyhole",
|
||||
"password2": "lock-keyhole",
|
||||
"code": "shield-check",
|
||||
}
|
||||
|
||||
classes = [widget.attrs.get("class", ""), css]
|
||||
if field.errors:
|
||||
classes.append(f"{css.split()[0]}-error")
|
||||
|
||||
@register.filter
|
||||
def field_icon(field):
|
||||
return FIELD_ICONS.get(field.name, "")
|
||||
|
||||
|
||||
@register.filter
|
||||
def daisy(field, css=None):
|
||||
"""Render a bound form field with the right daisyUI classes.
|
||||
|
||||
Pass ``css`` to override them — the icon-in-field layout wraps the input in a
|
||||
``label.input``, and there the input itself must NOT carry the ``input`` class
|
||||
(daisyUI styles the wrapper instead), so it is rendered with ``grow``. The error
|
||||
state then belongs on the wrapper too, which is why an override skips it here.
|
||||
"""
|
||||
widget = field.field.widget
|
||||
|
||||
if css is None:
|
||||
css = next((css for widget_type, css in WIDGET_CLASSES if isinstance(widget, widget_type)), DEFAULT_WIDGET_CLASS)
|
||||
if field.errors:
|
||||
css = f"{css} {css.split()[0]}-error"
|
||||
|
||||
attrs = dict(widget.attrs)
|
||||
attrs["class"] = " ".join(part for part in classes if part)
|
||||
attrs["class"] = " ".join(part for part in [widget.attrs.get("class", ""), css] if part)
|
||||
return field.as_widget(attrs=attrs)
|
||||
|
||||
@@ -19,7 +19,7 @@ from teams.models import Position, Team, TeamMembership
|
||||
from .services.admins import grant_club_admin
|
||||
from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access
|
||||
from .services.statistics import club_statistics, clubs_with_totals, platform_totals
|
||||
from .templatetags.ui import as_alert, daisy
|
||||
from .templatetags.ui import as_alert, daisy, field_icon
|
||||
|
||||
User = get_user_model()
|
||||
Flag = get_waffle_flag_model()
|
||||
@@ -455,3 +455,61 @@ class MessageRenderingTests(ControlPanelTestBase):
|
||||
self.assertContains(response, "alert alert-soft alert-warning")
|
||||
self.assertContains(response, '<div class="font-bold">Careful</div>', html=False)
|
||||
self.assertContains(response, "<svg") # the lucide icon
|
||||
|
||||
|
||||
class FieldRenderingTests(TestCase):
|
||||
def field(self, form_field, name="email", errors=False):
|
||||
class Form(forms.Form):
|
||||
pass
|
||||
|
||||
Form.base_fields[name] = form_field
|
||||
form = Form(data={} if errors else None)
|
||||
if errors:
|
||||
form.full_clean()
|
||||
return form[name]
|
||||
|
||||
def test_known_fields_get_an_icon_and_others_do_not(self):
|
||||
self.assertEqual(field_icon(self.field(forms.EmailField(), "email")), "mail")
|
||||
self.assertEqual(field_icon(self.field(forms.CharField(), "password")), "lock-keyhole")
|
||||
self.assertEqual(field_icon(self.field(forms.CharField(), "note")), "")
|
||||
|
||||
def test_the_default_rendering_styles_the_input_itself(self):
|
||||
self.assertIn('class="input input-bordered w-full"', str(daisy(self.field(forms.CharField()))))
|
||||
|
||||
def test_an_override_replaces_the_daisy_classes(self):
|
||||
# The icon layout puts `input` on the wrapping label, so the input must not
|
||||
# carry it too — that would draw a box inside a box.
|
||||
rendered = str(daisy(self.field(forms.CharField()), "grow"))
|
||||
|
||||
self.assertIn('class="grow"', rendered)
|
||||
self.assertNotIn("input-bordered", rendered)
|
||||
|
||||
def test_an_override_leaves_the_error_state_to_the_wrapper(self):
|
||||
rendered = str(daisy(self.field(forms.CharField(required=True), errors=True), "grow"))
|
||||
|
||||
self.assertNotIn("grow-error", rendered)
|
||||
|
||||
def test_the_default_rendering_marks_errors_on_the_input(self):
|
||||
rendered = str(daisy(self.field(forms.CharField(required=True), errors=True)))
|
||||
|
||||
self.assertIn("input-error", rendered)
|
||||
|
||||
|
||||
class LoginFormRenderingTests(TestCase):
|
||||
def setUp(self):
|
||||
self.response = self.client.get(reverse("account_login"))
|
||||
|
||||
def test_the_fields_carry_an_icon_and_no_visible_label(self):
|
||||
self.assertContains(self.response, 'class="sr-only">Email</span>')
|
||||
self.assertContains(self.response, 'class="sr-only">Password</span>')
|
||||
self.assertNotContains(self.response, '<span class="label-text">Email</span>')
|
||||
self.assertContains(self.response, 'placeholder="Email address"')
|
||||
|
||||
def test_the_checkbox_keeps_its_visible_label(self):
|
||||
self.assertContains(self.response, '<span class="label-text">Remember Me</span>')
|
||||
|
||||
def test_the_password_reset_link_is_spaced_and_addressable(self):
|
||||
# The input's aria-describedby points here; without the id it dangles.
|
||||
self.assertContains(self.response, 'id="id_password_helptext"')
|
||||
self.assertContains(self.response, "mt-3")
|
||||
self.assertContains(self.response, "Forgot your password?")
|
||||
|
||||
3897
static/css/app.css
3897
static/css/app.css
File diff suppressed because one or more lines are too long
@@ -1,4 +1,16 @@
|
||||
{% load ui %}
|
||||
{% load lucide ui %}
|
||||
|
||||
{% comment %}
|
||||
allauth passes `unlabeled=True` on the entrance forms (login, signup, reset) and
|
||||
already sets a placeholder on each of their fields ("Email address", "Password"),
|
||||
so dropping the visible label loses nothing on screen. The label is still emitted
|
||||
sr-only inside the wrapper: a placeholder is not a label, and it disappears the
|
||||
moment you start typing.
|
||||
|
||||
Fields with an icon use daisyUI's icon-in-field layout, where the `input` class
|
||||
goes on the *wrapping label* and the input itself carries only `grow`. Putting
|
||||
`input` on both draws a box inside a box.
|
||||
{% endcomment %}
|
||||
{% for field in attrs.form.hidden_fields %}{{ field }}{% endfor %}
|
||||
{% for error in attrs.form.non_field_errors %}
|
||||
<div class="alert alert-error my-2">
|
||||
@@ -13,12 +25,30 @@
|
||||
<span class="label-text">{{ field.label }}</span>
|
||||
</label>
|
||||
{% else %}
|
||||
<label class="label" for="{{ field.id_for_label }}">
|
||||
<span class="label-text">{{ field.label }}</span>
|
||||
</label>
|
||||
{{ field|daisy }}
|
||||
{% if not attrs.unlabeled %}
|
||||
<label class="label" for="{{ field.id_for_label }}">
|
||||
<span class="label-text">{{ field.label }}</span>
|
||||
</label>
|
||||
{% endif %}
|
||||
{% with icon=field|field_icon %}
|
||||
{% if icon %}
|
||||
<label class="input flex w-full items-center gap-2 {% if field.errors %}input-error{% endif %}" for="{{ field.id_for_label }}">
|
||||
<span class="opacity-50">{% lucide icon size=16 %}</span>
|
||||
{% if attrs.unlabeled %}<span class="sr-only">{{ field.label }}</span>{% endif %}
|
||||
{{ field|daisy:"grow" }}
|
||||
</label>
|
||||
{% else %}
|
||||
{{ field|daisy }}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
|
||||
{% comment %}
|
||||
The password field's help_text is allauth's "Forgot your password?" link, so it
|
||||
gets room to breathe. The id is not decorative: Django points the input's
|
||||
aria-describedby at `<auto_id>_helptext`, and without it that reference dangles
|
||||
and the link is never announced.
|
||||
{% endcomment %}
|
||||
{% if field.help_text %}<span id="{{ field.auto_id }}_helptext" class="label-text-alt mt-3 block text-base-content/70">{{ field.help_text }}</span>{% endif %}
|
||||
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
{% block main %}
|
||||
<div class="flex justify-center">
|
||||
<div class="card w-full max-w-xl bg-base-100 shadow">
|
||||
<div class="card w-full max-w-xl bg-base-100 border-2 border-base-content/20 shadow-xl">
|
||||
<div class="card-body prose max-w-none">
|
||||
{% block content %}{% endblock content %}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user