diff --git a/core/static/bundled/alpine-index.js b/core/static/bundled/alpine-index.js
index 211600a5..4e04e7dd 100644
--- a/core/static/bundled/alpine-index.js
+++ b/core/static/bundled/alpine-index.js
@@ -1,7 +1,8 @@
+import { limitedChoices } from "#core:alpine/limited-choices";
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
-Alpine.plugin(sort);
+Alpine.plugin([sort, limitedChoices]);
window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => {
diff --git a/core/static/bundled/alpine/limited-choices.ts b/core/static/bundled/alpine/limited-choices.ts
new file mode 100644
index 00000000..211441d0
--- /dev/null
+++ b/core/static/bundled/alpine/limited-choices.ts
@@ -0,0 +1,69 @@
+import type { Alpine as AlpineType } from "alpinejs";
+
+export function limitedChoices(Alpine: AlpineType) {
+ /**
+ * Directive to limit the number of elements
+ * that can be selected in a group of checkboxes.
+ *
+ * When the max numbers of selectable elements is reached,
+ * new elements will still be inserted, but oldest ones will be deselected.
+ * For example, if checkboxes A, B and C have been selected and the max
+ * number of selections is 3, then selecting D will result in having
+ * B, C and D selected.
+ *
+ * # Example in template
+ * ```html
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * ```
+ */
+ Alpine.directive(
+ "limited-choices",
+ (el, { expression }, { evaluateLater, effect }) => {
+ const getMaxChoices = evaluateLater(expression);
+ let maxChoices: number;
+ const inputs: HTMLInputElement[] = Array.from(
+ el.querySelectorAll("input[type='checkbox']"),
+ );
+ const checked = [] as HTMLInputElement[];
+
+ const manageDequeue = () => {
+ if (checked.length <= maxChoices) {
+ // There isn't too many checkboxes selected. Nothing to do
+ return;
+ }
+ const popped = checked.splice(0, checked.length - maxChoices);
+ for (const p of popped) {
+ p.checked = false;
+ }
+ };
+
+ for (const input of inputs) {
+ input.addEventListener("change", (_e) => {
+ if (input.checked) {
+ checked.push(input);
+ } else {
+ checked.splice(checked.indexOf(input), 1);
+ }
+ manageDequeue();
+ });
+ }
+ effect(() => {
+ getMaxChoices((value: string) => {
+ const previousValue = maxChoices;
+ maxChoices = Number.parseInt(value);
+ if (maxChoices < previousValue) {
+ // The maximum number of selectable items has been lowered.
+ // Some currently selected elements may need to be removed
+ manageDequeue();
+ }
+ });
+ });
+ },
+ );
+}
diff --git a/election/templates/election/election_detail.jinja b/election/templates/election/election_detail.jinja
index 28b2a956..2c628211 100644
--- a/election/templates/election/election_detail.jinja
+++ b/election/templates/election/election_detail.jinja
@@ -63,7 +63,13 @@
{%- for role in role_list %}
{%- set count = [0] %}
{%- set role_data = election_form.data.getlist(role.title) if role.title in election_form.data else [] %}
-
+
+ 1 -%}
+ x-data x-limited-choices="{{ role.max_choice }}"
+ {%- endif %}
+ class="role {% if role.title in election_form.errors %}role_error{% endif %}"
+ >