Some motivation
Django has a field called ChoiceField which lets you very easily create a <select>
dropdown in your forms. A great feature is the ability to transform nested choices into appropriate <optgroup>
groups. For instance, the following code:
my_choices = [("Group 1", [ (1, "Choice 1"), (2, "Choice 2")]), ("Group 2", [(3, "Choice 3"), (4, "Choice 4")]), (5, "Choice 5")] field = ChoiceField(label="Field", choices=my_choices)
…creates a field that when rendered looks something like this:

The HTML that Django produced looks like this (excluding the label):
<select id="id_test" name="test"> <optgroup label="Group 1"> <option value="1">Choice 1</option> <option value="2">Choice 2</option> </optgroup> <optgroup label="Group 2"> <option value="3">Choice 3</option> <option value="4">Choice 4</option> </optgroup> <option value="5">Choice 5</option> </select>
Nice. So, what happens when we try to add more levels of nesting in the choices?
my_choices = [ ("Group 1", [ (0, "Choice 0"), ("Subgroup 1", [ (1, "Choice 1")]), ("Subgroup 2", [ (2, "Choice 2")])]), ("Group 2",[ ("Subgroup 3", [ (3, "Choice 3")]), ("Subgroup 4", [(4, "Choice 4")])]), (5, "Choice 5")]

That didn’t work. Here’s the HTML Django gave us:
<select id="id_test" name="test"> <optgroup label="Group 1"> <option value="0">Choice 0</option> <option value="Subgroup 1">[(1, 'Choice 1')]</option> <option value="Subgroup 2">[(2, 'Choice 2')]</option> </optgroup> <optgroup label="Group 2"> <option value="Subgroup 3">[(3, 'Choice 3')]</option> <option value="Subgroup 4">[(4, 'Choice 4')]</option> </optgroup> <option value="5">Choice 5</option> </select>
The problem
As it turns out, ChoiceField only supports one level of nesting. Five years ago someone submitted a patch to correct the problem, but it was rejected for good reason: HTML itself only officially supports one level of <optgroup>
nesting.
To see if it would work anyway, I manually applied the patch to my Django installation. Although the patch itself worked (Django produced the nested option groups), Chrome wasn’t having any of it. My dropdown displayed incorrectly, and developer tools showed that the HTML got mangled during parsing.
Emulating nested optgroups
My solution to the problem is to fake it by using disabled <option>
elements as group headers. Then, we apply indentation to the actual options to make them appear to belong to the groups. This is accomplished by a custom widget called NestedSelect
(below), which is a cross between the original Select
widget and the patch I linked to above.
from itertools import chain from django.forms.widgets import Widget from django.utils.encoding import force_text from django.utils.html import format_html from django.utils.safestring import mark_safe from django.forms.utils import flatatt class NestedSelect(Widget): allow_multiple_selected = False def __init__(self, attrs=None, choices=()): super(NestedSelect, self).__init__(attrs) # choices can be any iterable, but we may need to render this widget # multiple times. Thus, collapse it into a list so it can be consumed # more than once. self.choices = list(choices) def render(self, name, value, attrs=None, choices=()): final_attrs = self.build_attrs(attrs, name=name) output = [format_html('<select{}>', flatatt(final_attrs))] selected_choices = set(force_text(v) for v in [value]) options = 'n'.join(self.process_list(selected_choices, chain(self.choices, choices))) if options: output.append(options) output.append('</select>') return mark_safe('n'.join(output)) def render_group(self, selected_choices, option_value, option_label, level=0): padding = " " * (level * 4) output = format_html("<option disabled>%s{}</option>" % padding, force_text(option_value)) output += "".join(self.process_list(selected_choices, option_label, level + 1)) return output def process_list(self, selected_choices, l, level=0): output = [] for option_value, option_label in l: if isinstance(option_label, (list, tuple)): output.append(self.render_group(selected_choices, option_value, option_label, level)) else: output.append(self.render_option(selected_choices, option_value, option_label, level)) return output def render_option(self, selected_choices, option_value, option_label, level): padding = " " * (level * 4) if option_value is None: option_value = '' option_value = force_text(option_value) if option_value in selected_choices: selected_html = mark_safe(' selected="selected"') if not self.allow_multiple_selected: # Only allow for a single selection. selected_choices.remove(option_value) else: selected_html = '' return format_html('<option value="{}"{}>%s{}</option>' % padding, option_value, selected_html, force_text(option_label))
Then it’s just a matter of swapping out the widget that ChoiceField
uses:
self.fields["test"] = ChoiceField(label="Field", choices=my_choices, widget=NestedSelect)
Now we get something closer to what we wanted:
Improving the aesthetics
The solution above works, but it can be improved in two ways:
- The manual addition of padding results in an ugly side-effect – that padding is visible in the selected option. Look at the difference when Choice 1 and Choice 5 are selected (below). Choice 5 is aligned correctly, but Choice 1 appears to be floating out in no-man’s land.
- We no longer have the nice bolding effect on the option group headers. It’s not possible for us to individually style the elements that are supposed to be headers.
To overcome these shortcomings of HTML, we must turn a JavaScript based replacement: Select2. With that plugin installed, the fix is just a little bit of JavaScript:
$(function () { $("select").each(function(){ // Ensure the final select box is wide enough to fit the bolded headings $(this).width($(this).width() * 1.1); }).select2({ templateSelection: function (selection) { return $.trim(selection.text); }, templateResult: function (option) { if ($(option.element).attr("disabled")) { return $("<b>" + option.text + "</b>").css("color", "black"); } else { return option.text; } } }); });
Finished product
The final results look nice, and you even get a free search box thanks to Select2:
Thank you so much!
You can’t image how much this helped me!
Thanks,
JSM
You’re welcome!!
it looks to me like there is a backslash missing in line 22 and 26: ‘n’.join(
i was wondering about why there are “n” characters in the browser-dom-explorer between the options and optgroups