mirror of
https://github.com/netbox-community/netbox.git
synced 2026-05-06 22:12:43 +08:00
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:
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
8
netbox/project-static/dist/netbox.js
vendored
8
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
6
netbox/project-static/dist/netbox.js.map
vendored
6
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────────────
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user