Merge pull request #21969 from netbox-community/21924-improve-styling-and-consistency-of-floating-bulk-actions

Closes #21924: Refactor sticky bulk actions and form bars
This commit is contained in:
bctiemann
2026-04-21 13:25:50 -04:00
committed by GitHub
13 changed files with 225 additions and 100 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,42 +1,95 @@
import { getElements } from '../util';
// Only target selection-driven sticky bars; 'always' bars are pure CSS
const stickyActionsSelector = '.sticky-actions[data-sticky-position][data-sticky-when="selection"]';
const selectionInputSelector = [
'input[type="checkbox"][name="pk"]',
'table tr th > input[type="checkbox"].toggle',
'#select-all',
].join(', ');
const checkedSelectionSelector = [
'input[type="checkbox"][name="pk"]:checked',
'table tr th > input[type="checkbox"].toggle:checked',
'#select-all:checked',
].join(', ');
const selectionControlSelector = [
'.bulk-action-buttons .btn',
'.bulk-action-buttons input:not([type="hidden"])',
'.bulk-action-buttons select',
'.bulk-action-buttons textarea',
].join(', ');
// Module-scoped guard: assumes this module is loaded exactly once per page.
let listenersBound = false;
/**
* Conditionally add and remove a class that will float the button group
* based on whether or not items in the list are checked
* Determine whether a sticky action group has an active selection in scope.
*/
function toggleFloat(): void {
const checkedCheckboxes = document.querySelector<HTMLInputElement>(
'input[type="checkbox"][name="pk"]:checked',
);
const buttonGroup = document.querySelector<HTMLDivElement>(
'div.form.form-horizontal div.btn-list',
);
if (!buttonGroup) {
return;
}
const isFloating = buttonGroup.classList.contains('btn-float-group-left');
if (checkedCheckboxes !== null && !isFloating) {
buttonGroup.classList.add('btn-float-group-left');
} else if (checkedCheckboxes === null && isFloating) {
buttonGroup.classList.remove('btn-float-group-left');
function hasSelection(scope: ParentNode): boolean {
return scope.querySelector<HTMLInputElement>(checkedSelectionSelector) !== null;
}
/**
* Enable or disable controls that require a selection.
*/
function setSelectionControlsDisabled(stickyActions: HTMLElement, disabled: boolean): void {
for (const control of stickyActions.querySelectorAll(selectionControlSelector)) {
if (
control instanceof HTMLButtonElement ||
control instanceof HTMLInputElement ||
control instanceof HTMLSelectElement ||
control instanceof HTMLTextAreaElement
) {
control.disabled = disabled;
} else if (control instanceof HTMLAnchorElement) {
control.classList.toggle('disabled', disabled);
control.setAttribute('aria-disabled', String(disabled));
if (disabled) {
control.tabIndex = -1;
} else {
control.removeAttribute('tabindex');
}
}
}
}
/**
* Initialize floating bulk buttons.
* Update the state of a sticky action group.
*/
function updateStickyActions(stickyActions: HTMLElement): void {
const scope = stickyActions.closest('form') ?? document;
const isActive = hasSelection(scope);
stickyActions.classList.toggle('is-sticky-active', isActive);
setSelectionControlsDisabled(stickyActions, !isActive);
}
/**
* Update all sticky action groups on the page.
*/
function syncStickyActions(): void {
for (const stickyActions of document.querySelectorAll<HTMLElement>(stickyActionsSelector)) {
updateStickyActions(stickyActions);
}
}
/**
* Initialize sticky action groups.
*/
export function initFloatBulk(): void {
for (const element of getElements<HTMLInputElement>('input[type="checkbox"][name="pk"]')) {
element.addEventListener('change', () => {
toggleFloat();
});
}
// Handle the select-all checkbox
for (const element of getElements<HTMLInputElement>(
'table tr th > input[type="checkbox"].toggle',
)) {
element.addEventListener('change', () => {
toggleFloat();
if (!listenersBound) {
document.addEventListener('change', (event: Event) => {
const target = event.target;
if (target instanceof HTMLInputElement && target.matches(selectionInputSelector)) {
syncStickyActions();
}
});
for (const eventName of ['htmx:afterSwap', 'htmx:oobAfterSwap']) {
document.body.addEventListener(eventName, syncStickyActions);
}
listenersBound = true;
}
syncStickyActions();
}

View File

@@ -34,28 +34,6 @@ span.color-label {
letter-spacing: .15rem;
}
// A floating div for form buttons
.btn-float-group {
position: sticky;
bottom: 10px;
z-index: 4;
}
.btn-float-group-left {
@extend .btn-float-group;
float: left;
}
.btn-float-group-right {
@extend .btn-float-group;
float: right;
}
// Override a transparent background
.btn-float {
--tblr-btn-bg: var(--#{$prefix}bg-surface-tertiary) !important;
}
.logo {
height: 80px;
}
@@ -118,3 +96,87 @@ html[data-bs-theme=dark] {
filter: grayscale(100%) invert(100%) brightness(80%);
}
}
// ── Sticky action bars ──────────────────────────────────────
// Base: shared by all sticky-action containers
.sticky-actions {
z-index: 4;
&[data-sticky-when='always'],
&.is-sticky-active {
position: sticky;
bottom: map.get($spacers, 1);
}
}
// Inline (list / children) variants selection-driven
.sticky-actions[data-sticky-position='left'],
.sticky-actions[data-sticky-position='right'] {
display: flex;
align-items: center;
gap: map.get($spacers, 2);
padding: map.get($spacers, 2);
max-width: 100%;
width: fit-content;
}
.sticky-actions[data-sticky-position='right'] {
margin-left: auto;
}
.sticky-actions[data-sticky-position='full'] {
max-width: 100%;
}
// Footer variant edit forms (always visible, sticks to bottom: 0)
// NOTE: requires `.sticky-actions` on the same element for `position: sticky`.
.sticky-actions-footer {
max-width: 100%;
margin-top: map.get($spacers, 4);
padding: map.get($spacers, 3);
padding-bottom: calc(#{map.get($spacers, 3)} + env(safe-area-inset-bottom, 0));
background: var(--#{$prefix}bg-surface-secondary);
border-top: 1px solid var(--#{$prefix}border-color);
&[data-sticky-when='always'],
&.is-sticky-active {
bottom: 0;
}
> .btn-list {
justify-content: flex-end;
margin-bottom: 0;
}
}
.object-edit--with-sticky-actions {
padding-bottom: calc(#{map.get($spacers, 4)} + env(safe-area-inset-bottom, 0));
}
// Disabled-button styling inside selection-driven bars
[data-sticky-when='selection'] .bulk-action-buttons .btn:disabled,
[data-sticky-when='selection'] .bulk-action-buttons .btn.disabled {
border-color: transparent;
box-shadow: none;
}
[data-sticky-when='selection'] .bulk-action-buttons .btn.disabled {
pointer-events: none;
}
// Legacy aliases — remove in v4.7.0
.btn-float-group-right {
position: sticky;
bottom: 10px;
z-index: 4;
margin-left: auto;
width: fit-content;
}
.btn-float-group-left {
position: sticky;
bottom: 10px;
z-index: 4;
width: fit-content;
}
// ── /Sticky action bars ─────────────────────────────────────

View File

@@ -6,7 +6,7 @@
{% block title %}{% trans "User Preferences" %}{% endblock %}
{% block content %}
<form method="post" action="" class="object-edit" hx-disable="true" id="preferences-update">
<form method="post" action="" class="object-edit object-edit--with-sticky-actions" hx-disable="true" id="preferences-update">
{% csrf_token %}
{# Built-in preferences #}
@@ -73,9 +73,12 @@
{% endif %}
</div>
</div>
<div class="text-end my-3">
<a href="{% url 'account:preferences' %}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
<button type="submit" name="_update" class="btn btn-primary">{% trans "Save" %}</button>
<div class="sticky-actions sticky-actions-footer d-print-none" data-sticky-position="full" data-sticky-when="always">
<div class="btn-list">
<a href="{% url 'account:preferences' %}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
<button type="submit" name="_update" class="btn btn-primary">{% trans "Save" %}</button>
</div>
</div>
</form>
{% endblock %}

View File

@@ -41,7 +41,7 @@ Context:
{# Edit form #}
<div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="edit-form-tab">
<form action="" method="post" class="form form-horizontal mt-5">
<form action="" method="post" class="form form-horizontal object-edit--with-sticky-actions mt-5">
<div id="form_fields" hx-disinherit="hx-select hx-swap">
{% csrf_token %}
{% if request.POST.return_url %}
@@ -123,9 +123,11 @@ Context:
{% endif %}
{% render_field form.background_job %}
</div>
</div>{# /form_fields #}
<div class="btn-float-group-right">
<a href="{{ return_url }}" class="btn btn-outline-secondary btn-float">{% trans "Cancel" %}</a>
<div class="sticky-actions sticky-actions-footer d-print-none" data-sticky-position="full" data-sticky-when="always">
<div class="btn-list">
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
<button type="submit" name="_apply" class="btn btn-primary">{% trans "Apply" %}</button>
</div>
</div>

View File

@@ -33,9 +33,11 @@ Context:
{% include 'htmx/table.html' %}
</div>
</div>
<div class="d-print-none mt-2">
<div class="card btn-list sticky-actions d-print-none" data-sticky-position="right" data-sticky-when="selection">
{% block bulk_controls %}
{% action_buttons actions model multi=True return_url=request.path %}
<div class="btn-list bulk-action-buttons">
{% action_buttons actions model multi=True return_url=request.path %}
</div>
{% block bulk_extra_controls %}{% endblock %}
{% endblock bulk_controls %}
</div>

View File

@@ -58,7 +58,7 @@ Context:
{% include 'inc/missing_prerequisites.html' %}
{% endif %}
<form action="" method="post" enctype="multipart/form-data" class="object-edit mt-5">
<form action="" method="post" enctype="multipart/form-data" class="object-edit object-edit--with-sticky-actions mt-5">
{% csrf_token %}
{% block pre_form_fields %}{% endblock pre_form_fields %}
@@ -68,24 +68,26 @@ Context:
{% endblock form %}
</div>
<div class="btn-float-group-right">
{% block buttons %}
<a href="{{ return_url }}" class="btn btn-outline-secondary btn-float">{% trans "Cancel" %}</a>
{% if object.pk %}
<button type="submit" name="_update" class="btn btn-primary">
{% trans "Save" %}
</button>
{% else %}
<div class="btn-group" role="group" aria-label="{% trans "Actions" %}">
<button type="submit" name="_create" class="btn btn-primary">
{% trans "Create" %}
<div class="sticky-actions sticky-actions-footer d-print-none" data-sticky-position="full" data-sticky-when="always">
<div class="btn-list">
{% block buttons %}
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
{% if object.pk %}
<button type="submit" name="_update" class="btn btn-primary">
{% trans "Save" %}
</button>
<button type="submit" name="_addanother" class="btn btn-outline-primary btn-float">
{% trans "Create & Add Another" %}
</button>
</div>
{% endif %}
{% endblock buttons %}
{% else %}
<div class="btn-group" role="group" aria-label="{% trans "Actions" %}">
<button type="submit" name="_create" class="btn btn-primary">
{% trans "Create" %}
</button>
<button type="submit" name="_addanother" class="btn btn-outline-primary">
{% trans "Create & Add Another" %}
</button>
</div>
{% endif %}
{% endblock buttons %}
</div>
</div>
</form>
</div>

View File

@@ -82,7 +82,7 @@ Context:
{% endblocktrans %}
</label>
</div>
<div class="bulk-action-buttons">
<div class="btn-list bulk-action-buttons">
{% action_buttons actions model multi=True %}
</div>
</div>
@@ -91,7 +91,6 @@ Context:
{% endif %}
<div class="form form-horizontal">
{% csrf_token %}
<input type="hidden" id="object-list-return-url" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
{# Warn of any missing prerequisite objects #}
@@ -108,9 +107,9 @@ Context:
{# /Objects table #}
{# Form buttons #}
<div class="btn-list d-print-none">
<div class="card btn-list sticky-actions d-print-none" data-sticky-position="right" data-sticky-when="selection">
{% block bulk_buttons %}
<div class="bulk-action-buttons">
<div class="btn-list bulk-action-buttons">
{% action_buttons actions model multi=True %}
</div>
{% endblock %}

View File

@@ -17,7 +17,7 @@
{% csrf_token %}
{% include 'htmx/form.html' %}
<div class="text-end">
<button type="button" class="btn btn-outline-secondary btn-float" data-bs-dismiss="modal" aria-label="Cancel">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" aria-label="Cancel">
{% trans "Cancel" %}
</button>
<button type="submit" name="_quickadd" class="btn btn-primary">

View File

@@ -28,7 +28,7 @@
{# Update the bulk action buttons with new query parameters #}
{% if actions and not table.embedded %}
<div class="bulk-action-buttons" hx-swap-oob="outerHTML:.bulk-action-buttons">
<div class="btn-list bulk-action-buttons" hx-swap-oob="outerHTML:.bulk-action-buttons">
{% action_buttons actions model multi=True %}
</div>
{% endif %}

View File

@@ -34,12 +34,14 @@
{% endif %}
</div>
</div>
<div class="btn-float-group-right me-1">
<button type="button" class="btn btn-outline-danger btn-float" data-reset-select>
<i class="mdi mdi-backspace"></i> {% trans "Reset" %}
</button>
<button type="submit" class="btn btn-primary">
<i class="mdi mdi-magnify"></i> {% trans "Search" %}
</button>
<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" %}
</button>
<button type="submit" class="btn btn-primary">
<i class="mdi mdi-magnify"></i> {% trans "Search" %}
</button>
</div>
</div>
</form>