Source code for dal_alight.widgets
from django import forms
from django.forms.widgets import ChoiceWidget
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from dal.widgets import QuerySetSelectMixin, WidgetMixin
from .media import alight_media
def _is_iterable(x):
try:
iter(x)
except TypeError:
return False
return True
[docs]
class AlightChoiceMixin(ChoiceWidget):
"""Choice plumbing for Alight widgets; rendering is in AlightWidgetMixin."""
# ChoiceWidget sets template_name = None; keep TextInput rendering working.
template_name = 'django/forms/widgets/text.html'
input_type = 'text'
[docs]
def get_context(self, name, value, attrs):
"""Text input context for the search field (no ChoiceWidget optgroups)."""
context = super(ChoiceWidget, self).get_context(name, value, attrs)
# Search input is always empty; ChoiceWidget.format_value would list-wrap.
context["widget"]["value"] = forms.TextInput.format_value(self, value)
return context
[docs]
class AlightMultipleMixin:
"""Multiple-selection behaviour for hidden value inputs."""
allow_multiple_selected = True
def value_from_datadict(self, data, files, name):
try:
return data.getlist(name)
except AttributeError:
val = data.get(name)
if val is None:
return []
if isinstance(val, list):
return val
return [val]
[docs]
class AlightWidgetMixin:
"""Mixin that renders the autocomplete-light web component shell.
The visible search field is a ``TextInput``; callers configure it with the
usual widget ``attrs``. Selected values are stored in hidden
``slot="values"`` inputs.
"""
@property
def media(self):
return alight_media()
def _iter_selected_options(self, name, value, attrs=None):
"""Yield selected options as flat dicts for deck/hidden inputs.
Alight has no optgroup UI; this hides Django's grouped optgroups() API.
"""
for _group, options, _index in self.optgroups(name, value, attrs=attrs):
for option in options:
if not option['selected']:
continue
val = option['value']
if val is None or val == '':
continue
yield option
def _render_values_and_deck(self, name, value, attrs=None):
"""Build hidden inputs and deck chips for the current selection."""
value = self.format_value(value)
hidden_parts = []
deck_parts = []
disabled = mark_safe(' disabled') if attrs and attrs.get('disabled') else ''
for option in self._iter_selected_options(name, value, attrs=attrs):
val = option['value']
label = option['label']
hidden_parts.append(format_html(
'<input type="hidden" name="{}" value="{}" slot="values" '
'data-label="{}"{}>',
name,
val,
label,
disabled,
))
deck_parts.append(format_html(
'<div data-value="{}">{}</div>',
val,
label,
))
values_html = mark_safe(''.join(hidden_parts))
if deck_parts:
deck_html = format_html(
'<span slot="deck">{}</span>',
mark_safe(''.join(deck_parts)),
)
else:
deck_html = '<span slot="deck"></span>'
return values_html, deck_html
def _render_search_input(self, name, attrs, renderer=None, **kwargs):
"""Render the visible search input via the TextInput ``render()`` MRO."""
# BoundField puts ``required`` in attrs for the field widget. The search
# input is auxiliary (values submit via hidden inputs); strip it so the
# browser does not run HTML5 validation on an empty search box.
search_attrs = {k: v for k, v in attrs.items() if k != 'required'}
search_attrs['slot'] = 'input'
search_attrs['autocomplete'] = 'off'
return super(AlightChoiceMixin, self).render(
f'{name}-input',
'',
attrs=search_attrs,
renderer=renderer,
**kwargs,
)
def render(self, name, value, attrs=None, renderer=None, **kwargs):
if hasattr(self.choices, 'field'):
self.choices.field.empty_label = None
final_attrs = self.build_attrs(self.attrs, attrs)
field_id = final_attrs.get('id') or name
values_html, deck_html = self._render_values_and_deck(
name, value, attrs=final_attrs
)
url_attr = format_html(' url="{}"', self.url) if self.url else ''
input_html = self._render_search_input(
name, final_attrs, renderer=renderer, **kwargs
)
input_el = format_html(
'<autocomplete-select-input slot="input"{}>{}</autocomplete-select-input>',
url_attr,
input_html,
)
conf = self.render_forward_conf(field_id)
multiple_attr = (
' data-multiple' if getattr(self, 'allow_multiple_selected', False) else ''
)
inner = values_html + deck_html + str(input_el) + conf
return mark_safe(format_html(
'<autocomplete-select{}>{}</autocomplete-select>',
mark_safe(multiple_attr),
mark_safe(inner),
))
# ---------------------------------------------------------------------------
# Queryset-backed widgets (FK / M2M)
# ---------------------------------------------------------------------------
[docs]
class ModelAlight(
AlightWidgetMixin,
QuerySetSelectMixin,
AlightChoiceMixin,
forms.TextInput,
):
"""Single-select autocomplete widget backed by a QuerySet."""
[docs]
class ModelAlightMultiple(
AlightWidgetMixin,
QuerySetSelectMixin,
AlightMultipleMixin,
AlightChoiceMixin,
forms.TextInput,
):
"""Multi-select autocomplete widget backed by a QuerySet."""
# ---------------------------------------------------------------------------
# List-backed widgets (non-queryset)
# ---------------------------------------------------------------------------
[docs]
class ListAlight(
AlightWidgetMixin,
WidgetMixin,
AlightChoiceMixin,
forms.TextInput,
):
"""Single-select autocomplete backed by ``AlightListView``.
Use alongside ``AlightListView`` on the server.
"""
def __init__(self, url=None, *args, **kwargs):
if url is None and args and isinstance(args[0], str):
url = args[0]
if not url:
raise ValueError(
'{} requires a url; client-side local filtering was removed.'
.format(self.__class__.__name__)
)
super().__init__(url, *args, **kwargs)
# ---------------------------------------------------------------------------
# Tag widget
# ---------------------------------------------------------------------------
[docs]
class TagAlight(
AlightWidgetMixin,
WidgetMixin,
AlightMultipleMixin,
AlightChoiceMixin,
forms.TextInput,
):
"""Free-text tag widget — value stored as comma-separated text.
Tags are not backed by a model: the tag text IS the option value.
Use alongside ``AlightListView`` with a ``create()`` method, or any view
that returns HTML fragments.
The stored field value is a comma-separated string (same as TagSelect2).
"""
def option_value(self, value):
return value
[docs]
def format_value(self, value):
if not isinstance(value, (tuple, list)):
value = [value]
values = set()
for v in value:
if not v:
continue
if isinstance(v, str):
parts = v.split(',')
elif _is_iterable(v):
parts = v
else:
parts = [v]
for part in parts:
values.add(self.option_value(str(part).strip()))
return values
def _iter_tag_values(self, value):
"""Yield individual tag strings from a raw value."""
if isinstance(value, str):
value = value.split(',')
for v in value:
if not v:
continue
yield self.option_value(str(v).strip())
def _iter_selected_options(self, name, value, attrs=None):
for v in self._iter_tag_values(value):
yield {'value': v, 'label': v, 'selected': True}
[docs]
def value_from_datadict(self, data, files, name):
values = super().value_from_datadict(data, files, name)
return ','.join(values)
[docs]
class TaggitAlight(TagAlight):
[docs]
def value_from_datadict(self, data, files, name):
value = super().value_from_datadict(data, files, name)
if value and ',' not in value:
value = '%s,' % value
return value
def option_value(self, value):
return value.tag.name if hasattr(value, 'tag') else value