Fixes #16851: Add missing Aria Labels (#22178)

* #16851 - Add missing Aria Labels

* #16851 - Add missing Aria Labels

* #16851 - Add missing Aria Labels

* fixes for form field labels

* cleanup

* cleanup

* cleanup

* cleanup

* cleanup

* cleanup

* cleanup
This commit is contained in:
Arthur Hanson 2026-05-13 14:38:30 -07:00 committed by GitHub
parent eeca184cb8
commit 7a3397798a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 219 additions and 103 deletions

View File

@ -690,7 +690,8 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
)
local_context_data = JSONField(
required=False,
label=''
label='',
widget=forms.Textarea(attrs={'aria-label': _('Local config context data')})
)
virtual_chassis = DynamicModelChoiceField(
label=_('Virtual chassis'),

View File

@ -109,6 +109,10 @@ class SavedFiltersMixin(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Ensure the underlying <select> has an accessible name even when Tom Select
# hides the original element (the visible UI is a JS-built combobox).
self.fields['filter_id'].widget.attrs['aria-label'] = _('Saved filter')
# Limit saved filters to those applicable to the form's model
if hasattr(self, 'model'):
object_type = ObjectType.objects.get_for_model(self.model)

View File

@ -10,8 +10,9 @@ from django.db.models import DateField, DateTimeField
from django.template import Context, Template
from django.urls import reverse
from django.utils.dateparse import parse_date
from django.utils.html import escape
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _
from django_tables2.columns import library
from django_tables2.utils import Accessor
@ -186,7 +187,8 @@ class ToggleColumn(tables.CheckBoxColumn):
'class': 'w-1',
},
'input': {
'class': 'form-check-input'
'class': 'form-check-input',
'aria-label': lambda record, value: format_lazy(_('Select {object}'), object=record),
}
}
super().__init__(*args, default=default, visible=visible, **kwargs)
@ -194,7 +196,10 @@ class ToggleColumn(tables.CheckBoxColumn):
@property
def header(self):
title_text = _('Toggle all')
return mark_safe(f'<input type="checkbox" class="toggle form-check-input" title="{title_text}" />')
return format_html(
'<input type="checkbox" class="toggle form-check-input" title="{}" aria-label="{}" />',
title_text, title_text,
)
class BooleanColumn(tables.Column):

View File

@ -44,7 +44,7 @@
<thead>
<tr>
<th>
<input type="checkbox" class="toggle form-check-input" title="{% trans "Toggle All" %}">
<input type="checkbox" class="toggle form-check-input" title="{% trans "Toggle All" %}" aria-label="{% trans "Toggle All" %}">
</th>
<th>{% trans "Table" %}</th>
<th>{% trans "Ordering" %}</th>
@ -55,7 +55,7 @@
{% for table, prefs in request.user.config.data.tables.items %}
<tr>
<td>
<input type="checkbox" name="pk" value="tables.{{ table }}" class="form-check-input" />
<input type="checkbox" name="pk" value="tables.{{ table }}" class="form-check-input" aria-label="{% blocktrans with name=table %}Select {{ name }}{% endblocktrans %}" />
</td>
<td>{{ table }}</td>
<td>{{ prefs.ordering|join:", "|placeholder }}</td>

View File

@ -20,7 +20,7 @@
<button id="add-components" type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
</button>
<ul class="dropdown-menu" aria-labeled-by="add-components">
<ul class="dropdown-menu" aria-labelledby="add-components">
{% if perms.dcim.add_consoleport %}
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">{% trans "Console Ports" %}</a></li>
{% endif %}

View File

@ -37,18 +37,18 @@
{% if object.device_type.is_child_device and object.parent_bay %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label">{% trans "Parent Device" %}</label>
<label for="id_parent_device" class="col-sm-3 col-form-label">{% trans "Parent Device" %}</label>
<div class="col">
<input class="form-control" value="{{ object.parent_bay.device }}" disabled />
<input id="id_parent_device" class="form-control" value="{{ object.parent_bay.device }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label">{% trans "Parent Bay" %}</label>
<label for="id_parent_bay" class="col-sm-3 col-form-label">{% trans "Parent Bay" %}</label>
<div class="col">
<div class="input-group">
<input class="form-control" value="{{ object.parent_bay.name }}" disabled />
<a href="{% url 'dcim:devicebay_depopulate' pk=object.parent_bay.pk %}" title="{% trans "Regenerate Slug" %}" class="btn btn-danger d-inline-flex align-items-center">
<i class="mdi mdi-close-thick"></i>&nbsp;{% trans "Remove" %}
<input id="id_parent_bay" class="form-control" value="{{ object.parent_bay.name }}" disabled />
<a href="{% url 'dcim:devicebay_depopulate' pk=object.parent_bay.pk %}" title="{% trans "Remove" %}" aria-label="{% trans "Remove" %}" class="btn btn-danger d-inline-flex align-items-center">
<i class="mdi mdi-close-thick" aria-hidden="true"></i>&nbsp;{% trans "Remove" %}
</a>
</div>
</div>

View File

@ -13,15 +13,15 @@
<h2 class="card-header">{% block title %}{% trans "Populate" %} {{ device_bay }}{% endblock %}</h2>
<div class="card-body">
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">{% trans "Parent Device" %}</label>
<label for="id_parent_device" class="col-sm-3 col-form-label text-lg-end">{% trans "Parent Device" %}</label>
<div class="col">
<input class="form-control" value="{{ device_bay.device }}" disabled />
<input id="id_parent_device" class="form-control" value="{{ device_bay.device }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">{% trans "Bay" %}</label>
<label for="id_bay" class="col-sm-3 col-form-label text-lg-end">{% trans "Bay" %}</label>
<div class="col">
<input class="form-control" value="{{ device_bay }}" disabled />
<input id="id_bay" class="form-control" value="{{ device_bay }}" disabled />
</div>
</div>
{% render_form form %}

View File

@ -12,10 +12,10 @@
<td>{{ obj.untagged_vlan|linkify:"vid" }}</td>
<td>{{ obj.untagged_vlan.name }}</td>
<td>
<input type="radio" name="untagged_vlan" value="{{ obj.untagged_vlan.pk }}" checked="checked" />
<input type="radio" name="untagged_vlan" value="{{ obj.untagged_vlan.pk }}" checked="checked" aria-label="{% blocktrans with vid=obj.untagged_vlan.vid name=obj.untagged_vlan.name %}Set VLAN {{ vid }} ({{ name }}) as untagged{% endblocktrans %}" />
</td>
<td>
<input type="checkbox" name="tagged_vlans" value="{{ obj.untagged_vlan.pk }}" />
<input type="checkbox" name="tagged_vlans" value="{{ obj.untagged_vlan.pk }}" aria-label="{% blocktrans with vid=obj.untagged_vlan.vid name=obj.untagged_vlan.name %}Tag VLAN {{ vid }} ({{ name }}){% endblocktrans %}" />
</td>
</tr>
{% endif %}
@ -24,10 +24,10 @@
<td>{{ vlan|linkify:"vid" }}</td>
<td>{{ vlan.name }}</td>
<td>
<input type="radio" name="untagged_vlan" value="{{ vlan.pk }}"{% if vlan == obj.untagged_vlan %} checked="checked"{% endif %} />
<input type="radio" name="untagged_vlan" value="{{ vlan.pk }}"{% if vlan == obj.untagged_vlan %} checked="checked"{% endif %} aria-label="{% blocktrans with vid=vlan.vid name=vlan.name %}Set VLAN {{ vid }} ({{ name }}) as untagged{% endblocktrans %}" />
</td>
<td>
<input type="checkbox" name="tagged_vlans" value="{{ vlan.pk }}" checked="checked" />
<input type="checkbox" name="tagged_vlans" value="{{ vlan.pk }}" checked="checked" aria-label="{% blocktrans with vid=vlan.vid name=vlan.name %}Tag VLAN {{ vid }} ({{ name }}){% endblocktrans %}" />
</td>
</tr>
{% endfor %}

View File

@ -7,7 +7,7 @@
<button id="add-components" type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add" %}
</button>
<ul class="dropdown-menu" aria-labeled-by="add-components">
<ul class="dropdown-menu" aria-labelledby="add-components">
{% if perms.dcim.add_devicetype %}
<li><a class="dropdown-item" href="{% url 'dcim:devicetype_add' %}?manufacturer={{ object.pk }}">
{% trans "Add Device Type" %}

View File

@ -17,7 +17,7 @@
<button id="add-components" type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
</button>
<ul class="dropdown-menu" aria-labeled-by="add-components">
<ul class="dropdown-menu" aria-labelledby="add-components">
{% if perms.dcim.add_consoleport %}
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={{ object.get_absolute_url }}">{% trans "Console Ports" %}</a></li>
{% endif %}

View File

@ -1,6 +1,6 @@
{% load i18n %}
<div class="text-end mb-4">
<select class="btn btn-outline-secondary no-ts rack-view">
<select class="btn btn-outline-secondary no-ts rack-view" aria-label="{% trans "Rack elevation view" %}">
<option value="images-and-labels" selected="selected">{% trans "Images and Labels" %}</option>
<option value="images-only">{% trans "Images only" %}</option>
<option value="labels-only">{% trans "Labels only" %}</option>

View File

@ -10,7 +10,7 @@
{% endblock %}
{% block content %}
<div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="object-list-tab">
<div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="edit-form-tab">
<form action="" method="post" enctype="multipart/form-data" class="object-edit">
{% render_errors vc_form %}
{% for form in formset %}
@ -100,7 +100,7 @@
</td>
<td>
{% if virtual_chassis.pk %}
<a href="{% url 'dcim:virtualchassis_remove_member' pk=device.pk %}?return_url={% url 'dcim:virtualchassis_edit' pk=virtual_chassis.pk %}" class="btn btn-danger{% if virtual_chassis.master == device %} disabled{% endif %}">
<a href="{% url 'dcim:virtualchassis_remove_member' pk=device.pk %}?return_url={% url 'dcim:virtualchassis_edit' pk=virtual_chassis.pk %}" title="{% trans "Remove member" %}" aria-label="{% trans "Remove member" %}" class="btn btn-danger{% if virtual_chassis.master == device %} disabled{% endif %}">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span>
</a>
{% endif %}

View File

@ -32,8 +32,8 @@ Context:
{# Link to model documentation #}
{% if settings.DOCS_ROOT and object.docs_url %}
<a href="{{ object.docs_url }}" target="_blank" class="btn btn-outline-secondary" title="{% trans "View model documentation" %}">
<i class="mdi mdi-help-circle"></i> {% trans "Help" %}
<a href="{{ object.docs_url }}" target="_blank" class="btn btn-outline-secondary" title="{% trans "View model documentation" %}" aria-label="{% trans "View model documentation" %}">
<i class="mdi mdi-help-circle" aria-hidden="true"></i> {% trans "Help" %}
</a>
{% endif %}
@ -51,7 +51,7 @@ Context:
{% endblock tabs %}
{% block content %}
<div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="object-list-tab">
<div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="edit-form-tab">
{# Warn about missing prerequisite objects #}
{% if prerequisite_model %}

View File

@ -38,14 +38,14 @@ Context:
{% block tabs %}
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="object-list-tab" data-bs-toggle="tab" data-bs-target="#object-list" type="button" role="tab" aria-controls="edit-form" aria-selected="true">
<a class="nav-link active" id="object-list-tab" data-bs-toggle="tab" data-bs-target="#object-list" type="button" role="tab" aria-controls="object-list" aria-selected="true">
{% trans "Results" %}
<span class="badge text-bg-secondary total-object-count">{% if table.page.paginator.count %}{{ table.page.paginator.count }}{% else %}{{ total_count|default:"0" }}{% endif %}</span>
</a>
</li>
{% if filter_form %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="filters-form-tab" data-bs-toggle="tab" data-bs-target="#filters-form" type="button" role="tab" aria-controls="object-list" aria-selected="false">
<button class="nav-link" id="filters-form-tab" data-bs-toggle="tab" data-bs-target="#filters-form" type="button" role="tab" aria-controls="filters-form" aria-selected="false">
{% trans "Filters" %}
{% if filter_form %}{% badge filter_form.changed_data|length bg_color="primary" %}{% endif %}
</button>

View File

@ -37,10 +37,10 @@
<div class="sticky-actions sticky-actions-footer d-print-none" data-sticky-position="full" data-sticky-when="always">
<div class="btn-list">
<button type="button" class="btn btn-outline-danger" data-reset-select>
<i class="mdi mdi-backspace"></i> {% trans "Reset" %}
<i class="mdi mdi-backspace" aria-hidden="true"></i> {% trans "Reset" %}
</button>
<button type="submit" class="btn btn-primary">
<i class="mdi mdi-magnify"></i> {% trans "Search" %}
<i class="mdi mdi-magnify" aria-hidden="true"></i> {% trans "Search" %}
</button>
</div>
</div>

View File

@ -1,10 +1,10 @@
{% load i18n %}
<div class="d-flex ms-2">
<button class="nav-link color-mode-toggle hide-theme-dark fs-2 p-0 text-secondary" title="{% trans "Enable dark mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
<i class="mdi mdi-lightbulb"></i>
<button type="button" class="nav-link color-mode-toggle hide-theme-dark fs-2 p-0 text-secondary" title="{% trans "Enable dark mode" %}" aria-label="{% trans "Enable dark mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
<i class="mdi mdi-lightbulb" aria-hidden="true"></i>
</button>
<button class="nav-link color-mode-toggle hide-theme-light fs-2 p-0 text-secondary" title="{% trans "Enable light mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
<i class="mdi mdi-lightbulb-on"></i>
<button type="button" class="nav-link color-mode-toggle hide-theme-light fs-2 p-0 text-secondary" title="{% trans "Enable light mode" %}" aria-label="{% trans "Enable light mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
<i class="mdi mdi-lightbulb-on" aria-hidden="true"></i>
</button>
</div>

View File

@ -1,10 +1,12 @@
{% load i18n %}
{% if notifications %}
<span class="text-primary" id="notifications-alert" hx-swap-oob="true">
<i class="mdi mdi-bell-ring"></i>
<span class="badge bg-red"></span>
<i class="mdi mdi-bell-ring" aria-hidden="true"></i>
<span class="badge bg-red" aria-hidden="true"></span>
<span class="visually-hidden">{% trans "You have unread notifications" %}</span>
</span>
{% else %}
<span class="text-muted" id="notifications-alert" hx-swap-oob="true">
<i class="mdi mdi-bell"></i>
<i class="mdi mdi-bell" aria-hidden="true"></i>
</span>
{% endif %}

View File

@ -19,12 +19,12 @@
{% if page.has_previous %}
<li class="page-item">
{% if htmx %}
<a href="#" hx-get="{{ table.htmx_url }}{% querystring request page=page.previous_page_number %}" class="page-link">
<i class="mdi mdi-chevron-left"></i>
<a href="#" hx-get="{{ table.htmx_url }}{% querystring request page=page.previous_page_number %}" class="page-link" aria-label="{% trans "Previous page" %}">
<i class="mdi mdi-chevron-left" aria-hidden="true"></i>
</a>
{% else %}
<a href="{% querystring request page=page.previous_page_number %}" class="page-link">
<i class="mdi mdi-chevron-left"></i>
<a href="{% querystring request page=page.previous_page_number %}" class="page-link" aria-label="{% trans "Previous page" %}">
<i class="mdi mdi-chevron-left" aria-hidden="true"></i>
</a>
{% endif %}
</li>
@ -33,17 +33,17 @@
{# Page numbers #}
{% for p in page.smart_pages %}
<li class="page-item{% if page.number == p %} active" aria-current="page{% endif %}">
<li class="page-item{% if page.number == p %} active{% endif %}"{% if page.number == p %} aria-current="page"{% endif %}>
{% if p and htmx %}
<a href="#" hx-get="{{ table.htmx_url }}{% querystring request page=p %}" class="page-link">
<a href="#" hx-get="{{ table.htmx_url }}{% querystring request page=p %}" class="page-link" aria-label="{% blocktrans with num=p %}Page {{ num }}{% endblocktrans %}">
{{ p }}
</a>
{% elif p %}
<a href="{% querystring request page=p %}" class="page-link {% if page.number == p %} active{% endif %}">
<a href="{% querystring request page=p %}" class="page-link {% if page.number == p %} active{% endif %}" aria-label="{% blocktrans with num=p %}Page {{ num }}{% endblocktrans %}">
{{ p }}
</a>
{% else %}
<span class="page-link" disabled>&hellip;</span>
<span class="page-link" disabled aria-hidden="true">&hellip;</span>
{% endif %}
</li>
{% endfor %}
@ -53,12 +53,12 @@
{% if page.has_next %}
<li class="page-item">
{% if htmx %}
<a href="#" hx-get="{{ table.htmx_url }}{% querystring request page=page.next_page_number %}" class="page-link">
<i class="mdi mdi-chevron-right"></i>
<a href="#" hx-get="{{ table.htmx_url }}{% querystring request page=page.next_page_number %}" class="page-link" aria-label="{% trans "Next page" %}">
<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
</a>
{% else %}
<a href="{% querystring request page=page.next_page_number %}" class="page-link">
<i class="mdi mdi-chevron-right"></i>
<a href="{% querystring request page=page.next_page_number %}" class="page-link" aria-label="{% trans "Next page" %}">
<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
</a>
{% endif %}
</li>

View File

@ -4,10 +4,11 @@
<div class="row mb-3" id="results">
<div class="col-auto d-print-none">
<div class="input-group input-group-flat me-2 quicksearch" hx-disinherit="hx-select hx-swap">
<label for="quicksearch" class="visually-hidden">{% trans "Quick search" %}</label>
<input type="search" results="5" name="q" id="quicksearch" class="form-control" placeholder="{% trans "Quick search" %}"
hx-get="{{ request.full_path }}" hx-target="#object_list" hx-trigger="keyup changed delay:500ms, search"/>
<span class="input-group-text py-1">
<a href="#" id="quicksearch_clear" class="invisible text-secondary"><i class="mdi mdi-close-circle"></i></a>
<a href="#" id="quicksearch_clear" class="invisible text-secondary" aria-label="{% trans "Clear quick search" %}"><i class="mdi mdi-close-circle" aria-hidden="true"></i></a>
</span>
{% block extra_table_controls %}{% endblock %}
</div>
@ -16,9 +17,10 @@
{% if filter_form %}
<div class="col-auto d-print-none">
<div class="input-group">
<div class="input-group-text">
<i class="mdi mdi-filter" title="{% trans "Saved filter" %}"></i>
</div>
<label for="{{ filter_form.filter_id.id_for_label }}" class="input-group-text">
<i class="mdi mdi-filter" title="{% trans "Saved filter" %}" aria-hidden="true"></i>
<span class="visually-hidden">{% trans "Saved filter" %}</span>
</label>
{{ filter_form.filter_id }}
</div>
</div>
@ -28,7 +30,7 @@
{% if request.user.is_authenticated and table_modal %}
<div class="table-configure btn-group">
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}" data-bs-target="#{{ table_modal }}" class="btn">
<i class="mdi mdi-cog"></i> {% trans "Configure Table" %}
<i class="mdi mdi-cog" aria-hidden="true"></i> {% trans "Configure Table" %}
</button>
{% if table.config_params or table_configs %}
<button type="button" class="btn dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">

View File

@ -1,10 +1,11 @@
{% load helpers %}
{% load i18n %}
<div class="toast toast-dark border-0 shadow-sm" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="10000">
<div class="toast-header text-bg-{{ status }}">
<i class="mdi mdi-{{ status|icon_from_status }} me-1"></i>
<i class="mdi mdi-{{ status|icon_from_status }} me-1" aria-hidden="true"></i>
{{ title }}
<button type="button" class="btn-close me-0 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
<button type="button" class="btn-close me-0 m-auto" data-bs-dismiss="toast" aria-label="{% trans "Close" %}"></button>
</div>
<div class="toast-body">
{{ message }}

View File

@ -4,7 +4,7 @@
{% if settings.RELEASE.features.help_center %}
{# Help center control #}
<a href="#" class="nav-link px-1" aria-label="{% trans "Help center" %}">
<i class="mdi mdi-forum-outline"></i>
<i class="mdi mdi-forum-outline" aria-hidden="true"></i>
</a>
{% endif %}
@ -21,7 +21,7 @@
{# User menu #}
<div class="nav-item dropdown">
<a href="#" class="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu">
<a href="#" class="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="{% trans "Open user menu" %}" aria-expanded="false" role="button">
<div class="d-xl-block ps-2">
<div>{{ request.user }}</div>
<div class="mt-1 small text-secondary">
@ -35,30 +35,30 @@
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
<a href="{% url 'account:profile' %}" class="dropdown-item">
<i class="dropdown-item-icon mdi mdi-account"></i> {% trans "Profile" %}
<i class="dropdown-item-icon mdi mdi-account" aria-hidden="true"></i> {% trans "Profile" %}
</a>
<a href="{% url 'account:bookmarks' %}" class="dropdown-item">
<i class="dropdown-item-icon mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
<i class="dropdown-item-icon mdi mdi-bookmark" aria-hidden="true"></i> {% trans "Bookmarks" %}
</a>
<a href="{% url 'account:subscriptions' %}" class="dropdown-item">
<i class="dropdown-item-icon mdi mdi-bell"></i> {% trans "Subscriptions" %}
<i class="dropdown-item-icon mdi mdi-bell" aria-hidden="true"></i> {% trans "Subscriptions" %}
</a>
<a href="{% url 'account:preferences' %}" class="dropdown-item">
<i class="dropdown-item-icon mdi mdi-wrench"></i> {% trans "Preferences" %}
<i class="dropdown-item-icon mdi mdi-wrench" aria-hidden="true"></i> {% trans "Preferences" %}
</a>
<a href="{% url 'account:usertoken_list' %}" class="dropdown-item">
<i class="dropdown-item-icon mdi mdi-key"></i> {% trans "API Tokens" %}
<i class="dropdown-item-icon mdi mdi-key" aria-hidden="true"></i> {% trans "API Tokens" %}
</a>
<hr class="dropdown-divider" />
<a href="{% url 'logout' %}" hx-disable="true" class="dropdown-item">
<i class="dropdown-item-icon mdi mdi-logout-variant"></i> {% trans "Log Out" %}
<i class="dropdown-item-icon mdi mdi-logout-variant" aria-hidden="true"></i> {% trans "Log Out" %}
</a>
</div>
</div>
{% else %}
<div class="btn-group align-items-center ps-2">
<a class="btn btn-primary" type="button" href="{% url 'login' %}?next={{ request.path }}">
<i class="mdi mdi-login-variant"></i> {% trans "Log In" %}
<i class="mdi mdi-login-variant" aria-hidden="true"></i> {% trans "Log In" %}
</a>
</div>
{% endif %}

View File

@ -21,7 +21,7 @@
<button id="add-components" type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
</button>
<ul class="dropdown-menu" aria-labeled-by="add-components">
<ul class="dropdown-menu" aria-labelledby="add-components">
{% if perms.virtualization.add_vminterface %}
<li><a class="dropdown-item" href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">
{% trans "Interfaces" %}

View File

@ -62,7 +62,7 @@ class CommentField(forms.CharField):
widget = widgets.MarkdownWidget
label = _('Comments')
help_text = _(
'<i class="mdi mdi-information-outline"></i> '
'<i class="mdi mdi-information-outline" aria-hidden="true"></i> '
'<a href="{url}" target="_blank" tabindex="-1">Markdown</a> syntax is supported'
).format(url=static('docs/reference/markdown/'))

View File

@ -6,11 +6,11 @@
{% endfor %}
{% if bookmark %}
<button type="submit" class="btn btn-cyan">
<i class="mdi mdi-bookmark-minus"></i> {% trans "Unbookmark" %}
<i class="mdi mdi-bookmark-minus" aria-hidden="true"></i> {% trans "Unbookmark" %}
</button>
{% else %}
<button type="submit" class="btn btn-cyan">
<i class="mdi mdi-bookmark-plus"></i> {% trans "Bookmark" %}
<i class="mdi mdi-bookmark-plus" aria-hidden="true"></i> {% trans "Bookmark" %}
</button>
{% endif %}
</form>

View File

@ -7,11 +7,11 @@
{% endfor %}
{% if subscription %}
<button type="submit" class="btn btn-cyan">
<i class="mdi mdi-bell-minus"></i> {% trans "Unsubscribe" %}
<i class="mdi mdi-bell-minus" aria-hidden="true"></i> {% trans "Unsubscribe" %}
</button>
{% else %}
<button type="submit" class="btn btn-cyan">
<i class="mdi mdi-bell-plus"></i> {% trans "Subscribe" %}
<i class="mdi mdi-bell-plus" aria-hidden="true"></i> {% trans "Subscribe" %}
</button>
{% endif %}
</form>

View File

@ -18,45 +18,45 @@
{# Include the "regenerate" button on slug fields #}
{% if field|widget_type == 'slugwidget' %}
<div class="input-group">
{{ field }}
<button type="button" title="{% trans "Regenerate Slug" %}" class="btn reslug">
<i class="mdi mdi-reload"></i>
{% render_field_with_aria field %}
<button type="button" title="{% trans "Regenerate Slug" %}" aria-label="{% trans "Regenerate Slug" %}" class="btn reslug">
<i class="mdi mdi-reload" aria-hidden="true"></i>
</button>
</div>
{# Render checkbox labels to the right of the field #}
{% elif field|widget_type == 'checkboxinput' %}
<div class="form-check mb-0">
{{ field }}
{% render_field_with_aria field %}
<label for="{{ field.id_for_label }}" class="form-check-label">
{{ label }}
</label>
{% if field.help_text %}
<span class="form-text">{{ field.help_text|safe }}</span>
<span class="form-text" id="{{ field.auto_id }}_helptext">{{ field.help_text|safe }}</span>
{% endif %}
</div>
{# Include a copy-to-clipboard button #}
{% elif 'data-clipboard' in field.field.widget.attrs %}
<div class="input-group">
{{ field }}
<button type="button" title="{% trans "Copy to clipboard" %}" class="btn copy-content" data-clipboard-target="#{{ field.id_for_label }}">
<i class="mdi mdi-content-copy"></i>
{% render_field_with_aria field %}
<button type="button" title="{% trans "Copy to clipboard" %}" aria-label="{% trans "Copy to clipboard" %}" class="btn copy-content" data-clipboard-target="#{{ field.id_for_label }}">
<i class="mdi mdi-content-copy" aria-hidden="true"></i>
</button>
</div>
{# Default field rendering #}
{% else %}
{{ field }}
{% render_field_with_aria field %}
{% endif %}
{# Display any error messages #}
{% if field.errors %}
<div class="form-text text-danger">
<div class="form-text text-danger" id="{{ field.auto_id }}_errors" role="alert">
{% for error in field.errors %}{{ error }}{% if not forloop.last %}<br />{% endif %}{% endfor %}
</div>
{% endif %}
{# Help text #}
{% if field.help_text and field|widget_type != 'checkboxinput' %}
<span class="form-text">{{ field.help_text|safe }}</span>
<span class="form-text" id="{{ field.auto_id }}_helptext">{{ field.help_text|safe }}</span>
{% endif %}
{# For bulk edit forms, include an option to nullify the field #}

View File

@ -1,6 +1,6 @@
{% load i18n %}
{% load form_helpers %}
<div class="field-group mb-5">
<div class="field-group mb-5"{% if heading %} role="group" aria-label="{{ heading }}"{% endif %}>
{% if heading %}
<div class="row">
<h2 class="col-9 offset-3">{{ heading }}</h2>
@ -25,14 +25,14 @@
{% elif layout == 'inline' %}
{# Multiple form fields on the same line #}
<div class="row mb-3">
<div class="row mb-3"{% if title %} role="group" aria-label="{{ title }}"{% endif %}>
<label class="col col-3 col-form-label text-lg-end">{{ title|default:'' }}</label>
{% for field in items %}
<div class="col mb-1">
{{ field }}
<div class="form-text">{% trans field.label %}</div>
{% render_field_with_aria field has_helptext=True %}
<div class="form-text" id="{{ field.auto_id }}_helptext">{% trans field.label %}</div>
{% if field.errors %}
<div class="form-text text-danger">
<div class="form-text text-danger" id="{{ field.auto_id }}_errors" role="alert">
{% for error in field.errors %}{{ error }}{% if not forloop.last %}<br />{% endif %}{% endfor %}
</div>
{% endif %}
@ -47,7 +47,7 @@
<ul class="nav nav-pills mb-1" role="tablist">
{% for tab in items %}
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="{{ tab.id }}_tab" data-bs-toggle="tab" aria-controls="{{ tab.id }}" data-bs-target="#{{ tab.id }}" class="nav-link {% if tab.active %}active{% endif %}">
<button role="tab" type="button" id="{{ tab.id }}_tab" data-bs-toggle="tab" aria-controls="{{ tab.id }}" aria-selected="{% if tab.active %}true{% else %}false{% endif %}" data-bs-target="#{{ tab.id }}" class="nav-link {% if tab.active %}active{% endif %}">
{% trans tab.title %}
</button>
</li>
@ -57,7 +57,7 @@
</div>
<div class="tab-content p-0 border-0">
{% for tab in items %}
<div class="tab-pane {% if tab.active %}active{% endif %}" id="{{ tab.id }}" role="tabpanel" aria-labeled-by="{{ tab.id }}_tab">
<div class="tab-pane {% if tab.active %}active{% endif %}" id="{{ tab.id }}" role="tabpanel" aria-labelledby="{{ tab.id }}_tab">
{% for field in tab.fields %}
{% render_field field %}
{% endfor %}

View File

@ -1,3 +1,4 @@
{% load i18n %}
{% load l10n %}
<div class="progress">
<div
@ -5,6 +6,7 @@
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="{{ utilization }}"
aria-label="{% blocktrans with value=utilization|floatformat:1 %}Utilization: {{ value }}%{% endblocktrans %}"
class="progress-bar {{ bar_class }}"
style="width: {{ utilization|unlocalize }}%;"
>

View File

@ -8,7 +8,7 @@
<div class="input-group mb-1 mt-2">
<div class="input-group-prepend">
<span class="input-group-text">
<i class="mdi mdi-magnify"></i>
<i class="mdi mdi-magnify" aria-hidden="true"></i>
</span>
</div>
<input type="text" name="q" value="" class="form-control" placeholder="{% trans "Search" %}" aria-label="{% trans "Search NetBox" %}">
@ -22,9 +22,9 @@
<li class="nav-item dropdown">
{# Menu heading #}
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" data-bs-auto-close="false" role="button" aria-expanded="false" >
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" data-bs-auto-close="false" role="button" aria-expanded="false" aria-label="{{ menu.label }}">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<i class="{{ menu.icon_class }}"></i>
<i class="{{ menu.icon_class }}" aria-hidden="true"></i>
</span>
<span class="nav-link-title">
{{ menu.label }}
@ -45,8 +45,8 @@
{% if buttons %}
<div class="btn-group ms-1">
{% for button in buttons %}
<a href="{{ button.url }}" class="btn btn-sm btn-{{ button.color|default:"outline" }} lh-2 px-2" title="{{ button.title }}">
<i class="{{ button.icon_class }}"></i>
<a href="{{ button.url }}" class="btn btn-sm btn-{{ button.color|default:"outline" }} lh-2 px-2" title="{{ button.title }}" aria-label="{{ button.title }}">
<i class="{{ button.icon_class }}" aria-hidden="true"></i>
</a>
{% endfor %}
</div>

View File

@ -6,13 +6,14 @@
<button
type="button"
title="{% trans "Open selector" %}"
aria-label="{% trans "Open selector" %}"
class="btn btn-outline-secondary ms-1"
data-bs-toggle="modal"
data-bs-target="#htmx-modal"
hx-get="{% url 'htmx_object_selector' %}?_model={{ widget.attrs.selector }}&target={{ widget.attrs.id }}"
hx-target="#htmx-modal-content"
>
<i class="mdi mdi-database-search-outline"></i>
<i class="mdi mdi-database-search-outline" aria-hidden="true"></i>
</button>
{% endif %}
{% if quick_add and not widget.attrs.disabled %}
@ -20,13 +21,14 @@
<button
type="button"
title="{% trans "Quick add" %}"
aria-label="{% trans "Quick add" %}"
class="btn btn-outline-secondary ms-1"
data-bs-toggle="modal"
data-bs-target="#htmx-modal"
hx-get="{{ quick_add.url }}?_quickadd=True&target={{ widget.attrs.id }}{% for k, v in quick_add.params.items %}&{{ k }}={{ v }}{% endfor %}"
hx-target="#htmx-modal-content"
>
<i class="mdi mdi-plus-circle"></i>
<i class="mdi mdi-plus-circle" aria-hidden="true"></i>
</button>
{% endif %}
</div>

View File

@ -1,6 +1,6 @@
{% load i18n %}
<div class="markdown-widget">
<ul class="nav nav-pills mb-1">
<ul class="nav nav-pills mb-1" role="tablist" aria-label="{% trans "Markdown editor mode" %}">
<li class="nav-item" role="presentation">
<button class="nav-link active " id="{{ widget.name }}-input-tab" data-bs-toggle="tab" data-bs-target="#{{ widget.name }}-input" type="button" role="tab" aria-controls="{{ widget.name }}-input" aria-selected="true">
{% trans "Write" %}
@ -17,7 +17,7 @@
{% include "django/forms/widgets/textarea.html" %}
</div>
<div class="tab-pane show" id="{{ widget.name }}-markdown-preview" role="tabpanel" aria-labelledby="{{ widget.name }}-markdown-preview-tab">
<div id="{{ widget.name }}-preview" class="preview px-3 py-2 border-1"></div>
<div id="{{ widget.name }}-preview" class="preview px-3 py-2 border-1" aria-live="polite"></div>
</div>
</div>
</div>

View File

@ -1,4 +1,7 @@
from django import template
import warnings
from django import forms, template
from django.conf import settings
from utilities.forms.rendering import InlineFields, M2MAddRemoveFields, ObjectAttribute, TabbedGroups
@ -7,6 +10,7 @@ __all__ = (
'render_custom_fields',
'render_errors',
'render_field',
'render_field_with_aria',
'render_form',
'widget_type',
)
@ -42,6 +46,56 @@ def widget_type(field):
return None
@register.simple_tag
def render_field_with_aria(field, has_helptext=None):
"""Render a bound form field with aria-describedby/aria-invalid/aria-label wired up."""
if has_helptext is None:
has_helptext = bool(field.help_text)
widget_attrs = field.field.widget.attrs
described_by = []
if field.errors:
described_by.append(f'{field.auto_id}_errors')
if has_helptext:
described_by.append(f'{field.auto_id}_helptext')
extra_attrs = {}
if described_by:
# Merge with any aria-describedby already set on the widget so we
# append to (rather than clobber) descriptions defined elsewhere.
existing = widget_attrs.get('aria-describedby', '').strip()
extra_attrs['aria-describedby'] = ' '.join(
filter(None, [existing, *described_by])
)
if field.errors:
extra_attrs['aria-invalid'] = 'true'
# Mirror field.label onto <select> widgets hidden by Tom Select
# (ts-hidden-accessible, tabindex=-1), where scanners drop the <label for=>
# association. Skip selects opted out of Tom Select (``.no-ts`` class or a
# ``size`` attribute) since they stay visible and keep their association.
#
# When a field has no label at all (label=''), we deliberately do NOT
# synthesize one from the field name: that would inject an untranslated
# English string into the rendered DOM and degrade the experience for
# non-English locales. In DEBUG we emit a warning so developers add a
# proper translated label on the field.
if 'aria-label' not in widget_attrs:
if isinstance(field.field.widget, forms.Select) and field.label:
tom_select_excluded = (
'no-ts' in widget_attrs.get('class', '').split()
or 'size' in widget_attrs
)
if not tom_select_excluded:
extra_attrs['aria-label'] = str(field.label)
elif not field.label and settings.DEBUG:
form_name = getattr(getattr(field, 'form', None), '__class__', type(None)).__name__
warnings.warn(
f"Form field {form_name}.{field.name} has no label; no aria-label "
"will be set. Add a translated label to the field for proper "
"accessibility.",
stacklevel=2,
)
return field.as_widget(attrs=extra_attrs)
#
# Inclusion tags
#

View File

@ -1,5 +1,7 @@
import warnings
from unittest.mock import patch
from django import forms
from django.template.loader import render_to_string
from django.test import TestCase, override_settings
@ -8,6 +10,7 @@ from dcim.models import Site
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField, CustomFieldChoiceSet
from utilities.templatetags.builtins.tags import badge, customfield_value, static_with_params
from utilities.templatetags.form_helpers import render_field_with_aria
from utilities.templatetags.helpers import _humanize_capacity, humanize_speed
@ -242,3 +245,42 @@ class HumanizeSpeedTestCase(TestCase):
def test_trailing_zeros_stripped(self):
"""Ensure trailing fractional zeros are stripped (5.500 → 5.5)."""
self.assertEqual(humanize_speed(5_500_000), '5.5 Gbps')
class RenderFieldWithAriaTestCase(TestCase):
"""
Test the render_field_with_aria template tag.
"""
def test_aria_describedby_includes_errors_and_helptext(self):
class TestForm(forms.Form):
name = forms.CharField(help_text='Hello', required=True)
form = TestForm({'name': ''})
self.assertFalse(form.is_valid())
html = render_field_with_aria(form['name'])
self.assertIn('aria-invalid="true"', html)
self.assertIn('id_name_errors', html)
self.assertIn('id_name_helptext', html)
@override_settings(DEBUG=True)
def test_missing_label_emits_debug_warning(self):
class TestForm(forms.Form):
dns_name = forms.CharField(label='')
form = TestForm()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter('always')
html = render_field_with_aria(form['dns_name'])
messages = [str(w.message) for w in caught]
self.assertTrue(
any('TestForm.dns_name' in m for m in messages),
f'Expected a warning naming TestForm.dns_name; got: {messages}',
)
# No aria-label should be synthesized — an untranslated English fallback
# would degrade accessibility under non-English locales.
self.assertNotIn('aria-label', html)

View File

@ -249,7 +249,8 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm):
)
local_context_data = JSONField(
required=False,
label=''
label='',
widget=forms.Textarea(attrs={'aria-label': _('Local config context data')})
)
config_template = DynamicModelChoiceField(
queryset=ConfigTemplate.objects.all(),