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:
commit
e14f27ec83
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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue