Skip to content

Instantly share code, notes, and snippets.

@logarytm
Last active May 27, 2019 22:29
jQuery checkbox tree
{% macro checkbox_tree(roles, level = 0) %}
{% import _self as macros %}
<ul{% if level == 0 %} class="roles"{% endif %}>
{% for role, child_roles in roles %}
<li class="role">
<div class="custom-control custom-checkbox">
<input class="custom-control-input" id="{{ role|lower }}" type="checkbox" name="role[{{ role }}]"
data-level="{{ level }}">
<label class="custom-control-label" for="{{ role|lower }}">{{ role }}</label>
</div>
{% if child_roles is not empty %}
{{ macros.checkbox_tree(child_roles, level + 1) }}
{% endif %}
</li>
{% endfor %}
</ul>
{% endmacro %}
// Sort the checkboxes by depth. This is necessary because in updateParentCheckboxes() changes must propagate
// upward (since parent checkboxes depend on the states of their descendants).
//
// [-] root checkbox (indeterminate -> checked) ^
// / \ /|\
// / \ |
// / \ |
// / \ |
// [X] cbx 1 [-] cbx 2 (indeterminate -> checked) | upward
// / | | \ | propagation
// / | | \ |
// / | | \ |
// / | | \ |
// [X] 1.1 [X] 1.2 [X] 2.1 [ ] 2.2 (2.2 goes checked) |
//
const $checkboxes = $('.role [type=checkbox]').sort(function toposort(a, b) {
return +b.getAttribute('data-level') - (+a.getAttribute('data-level'));
});
// Whether indeterminate checkboxes should be checked.
const indeterminateMeansChecked = false;
// For each checkbox having child checkboxes, accumulate their states and update the parent.
// This (un)checks the parent checkbox if all its descendants are (un)checked, or otherwise sets it to
// indeterminate state.
//
// This function traverses the whole checkbox tree whereas in theory it could be limited to a subtree
// containing the recently changed checkbox. However, this optimization has not been implemented.
function updateParentCheckboxes() {
$checkboxes.each(function updateParentCheckbox() {
const $this = $(this);
const $thisRole = $this.closest('.role');
const $childRoles = $thisRole.find('.role');
// We convert to array because it's easier to use Array#every() this way.
const childCheckboxes = $childRoles.find('[type=checkbox]').toArray();
// If this checkbox has no children, do nothing.
if (childCheckboxes.length === 0) {
return;
}
if (childCheckboxes.every(x => x.checked)) {
// If all child checkboxes are checked, check the parent checkbox.
$this.prop('indeterminate', false);
$this.prop('checked', true);
} else if (childCheckboxes.every(x => !x.checked)) {
// Inverse case.
$this.prop('indeterminate', false);
$this.prop('checked', false);
} else {
// Some but not all child checkboxes checked. The parent is in indeterminate state.
$this.prop('checked', indeterminateMeansChecked);
$this.prop('indeterminate', true);
}
});
}
// If there are child checkboxes, set them all to the same state as the parent, recursively.
function propagateToChildCheckboxes($parent) {
const $parentRole = $parent.closest('.role');
const $descendants = $parentRole.find('[type=checkbox]');
$descendants.prop('checked', $parent.prop('checked'));
}
$checkboxes.change(function handleChangedCheckbox() {
propagateToChildCheckboxes($(this));
updateParentCheckboxes();
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment