Skip to main content
brickwork
GitHub Log in Get started
Display
Theme
Density
Direction
Brand

Documentation

Slide over

An edge-anchored dialog with a full-page fallback.

All components Component Brickwork 4.3.1

Try it

detail

Open full-page preview

Open the configured package slide-over and dismiss it with its close control, Escape or permitted backdrop click. The same URL is a complete page without JavaScript.

Use for supplementary focused tasks that should sit beside page content.

Use it

Working invocation

The live demonstration uses this site's routes. For a portable implementation, download the complete Django and Brickwork cookbook: every file below is shown exactly as it ships in the archive.

Download the consumer cookbook

component_cookbook/views.py

Python

"""Deterministic interaction endpoints: no models, storage, or external calls."""
from __future__ import annotations

from django.contrib import messages
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.cache import patch_vary_headers
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from django.views.decorators.http import require_http_methods, require_POST

from .forms import FilterForm, LanguageForm, MessageForm, UploadForm

LANGUAGES = (("python", "Python"), ("django", "Django"), ("htmx", "HTMX"), ("alpine", "Alpine"))
ROWS = tuple({"name": name, "status": status} for name, status in (
    ("Modal response", "ready"), ("Combobox endpoint", "draft"), ("Toast delivery", "ready"),
    ("No-JavaScript floor", "draft"), ("Slide-over focus", "ready"), ("Tooltip focus", "draft"),
    ("Dropdown links", "ready"), ("Native disclosure", "draft"), ("Tag input carrier", "ready"),
    ("Dropzone metadata", "draft"), ("Table selection", "ready"), ("Search request", "draft"),
))


def _htmx(request: HttpRequest) -> bool:
    return request.headers.get("HX-Request") == "true"


def _branch(request: HttpRequest, partial: str, page: str, context: dict, status: int = 200) -> HttpResponse:
    response = render(request, partial if _htmx(request) else page, context, status=status)
    patch_vary_headers(response, ("HX-Request",))
    return response


def _page(request: HttpRequest, template: str, **context) -> HttpResponse:
    return render(request, template, context)


def index(request): return _page(request, "component_cookbook/index.html")
def dropdown(request):
    return _page(request, "component_cookbook/dropdown.html", items=[
        {"label": _("Open cookbook"), "url": reverse("component_cookbook:index"), "icon": "external-link"},
        {"divider": True}, {"label": _("Delete draft"), "url": "#delete", "variant": "danger", "icon": "trash"},
    ])
def tabs(request):
    active = request.GET.get("tab", "overview")
    if active not in {"overview", "api"}:
        return HttpResponseBadRequest("Unknown cookbook tab.")
    return _page(request, "component_cookbook/tabs.html", active=active, tabs=[{"key": "overview", "label": _("Overview")}, {"key": "api", "label": "API"}], overview=mark_safe("<p>Overview content comes from this consumer template.</p>"), api=mark_safe("<p>The API panel uses the same package tab relationship.</p>"))
def tooltip(request): return _page(request, "component_cookbook/tooltip.html", tooltip_id="cookbook-tooltip", text=_("Explains the button"), placement="top")
def disclosure(request): return _page(request, "component_cookbook/disclosure.html")
def tag_input(request): return _page(request, "component_cookbook/tag_input.html")


def modal(request):
    return _branch(request, "component_cookbook/partials/modal.html", "component_cookbook/modal.html", {"form": MessageForm(), "modal_id": "cookbook-message-modal", "close_href": reverse("component_cookbook:index")})


@require_POST
def modal_submit(request):
    form = MessageForm(request.POST)
    context = {"form": form, "modal_id": "cookbook-message-modal", "close_href": reverse("component_cookbook:index")}
    if not form.is_valid():
        return _branch(request, "component_cookbook/partials/modal.html", "component_cookbook/modal.html", context, 422 if _htmx(request) else 200)
    if _htmx(request):
        response = HttpResponse(status=204)
        response["HX-Trigger"] = '{"bw:modal:close": {"id": "cookbook-message-modal"}}'
        return response
    messages.success(request, _("The demo accepted your message without saving it."))
    return redirect("component_cookbook:modal")


def slide_over(request):
    size = request.GET.get("size", "md")
    placement = request.GET.get("placement", "end")
    if size not in {"sm", "md", "lg"} or placement not in {"start", "end"}:
        return HttpResponseBadRequest("Invalid slide-over options.")
    return _branch(request, "component_cookbook/partials/slide_over.html", "component_cookbook/slide_over.html", {"close_href": reverse("component_cookbook:index"), "size": size, "placement": placement})


@require_POST
def toggle(request):
    return _branch(request, "component_cookbook/partials/toggle_outcome.html", "component_cookbook/toggle.html", {"enabled": request.POST.get("notices") == "on"})


@require_POST
def toast(request):
    if not _htmx(request):
        messages.success(request, _("The server delivered this demonstration outcome."))
        response = redirect("component_cookbook:toast-page")
    else:
        response = render(request, "component_cookbook/partials/toast_oob.html")
    patch_vary_headers(response, ("HX-Request",))
    return response


def toast_page(request):
    return _page(request, "component_cookbook/toast_region.html")


def toast_region(request):
    return _page(request, "component_cookbook/toast_region.html")


def combobox(request): return _page(request, "component_cookbook/combobox.html", form=LanguageForm(), options_url=reverse("component_cookbook:combobox-options"))


def combobox_options(request):
    query = request.GET.get("q", "").strip().casefold()[:40]
    return render(request, "component_cookbook/partials/combobox_options.html", {"choices": [choice for choice in LANGUAGES if not query or query in choice[1].casefold()]})


@require_POST
def combobox_submit(request):
    form = LanguageForm(request.POST)
    context = {"form": form, "options_url": reverse("component_cookbook:combobox-options")}
    if form.is_valid():
        context["selected_label"] = dict(LANGUAGES)[form.cleaned_data["language"]]
    return _branch(request, "component_cookbook/partials/combobox_form.html", "component_cookbook/combobox.html", context, 422 if _htmx(request) and not form.is_valid() else 200)


def _rows(query: str, status: str):
    return [row for row in ROWS if (not query or query.casefold() in row["name"].casefold()) and (not status or row["status"] == status)]


def table(request):
    form = FilterForm(request.GET)
    query, status = (form.cleaned_data["q"], form.cleaned_data["status"]) if form.is_valid() else ("", "")
    context = {"filter_form": form, "page_obj": Paginator(_rows(query, status), 4).get_page(request.GET.get("page", 1)), "table_url": reverse("component_cookbook:table"), "form_invalid": not form.is_valid()}
    return _branch(request, "component_cookbook/partials/table_region.html", "component_cookbook/table.html", context)


def search(request):
    query = request.GET.get("q", "").strip()[:40]
    return _page(request, "component_cookbook/search.html", query=query, result_count=len(_rows(query, "")), search_url=reverse("component_cookbook:search"))


@require_http_methods(["GET", "POST"])
def upload(request):
    if request.method == "GET":
        return _page(request, "component_cookbook/upload.html", form=UploadForm())
    form = UploadForm(request.POST, request.FILES)
    context = {"form": form}
    if form.is_valid():
        uploaded = form.cleaned_data["file"]
        context["outcome"] = _("Validated %(name)s (%(size)s bytes). The application did not retain it.") % {"name": uploaded.name, "size": uploaded.size}
        context["form"] = UploadForm()
    return _branch(request, "component_cookbook/partials/upload_form.html", "component_cookbook/upload.html", context, 422 if _htmx(request) and not form.is_valid() else 200)

component_cookbook/urls.py

Python

from django.urls import path

from . import views

app_name = "component_cookbook"

urlpatterns = [
    path("", views.index, name="index"),
    path("dropdown/", views.dropdown, name="dropdown"),
    path("tabs/", views.tabs, name="tabs"),
    path("tooltip/", views.tooltip, name="tooltip"),
    path("disclosure/", views.disclosure, name="disclosure"),
    path("tag-input/", views.tag_input, name="tag-input"),
    path("modal/", views.modal, name="modal"),
    path("modal/submit/", views.modal_submit, name="modal-submit"),
    path("slide-over/", views.slide_over, name="slide-over"),
    path("toggle/", views.toggle, name="toggle"),
    path("toasts/", views.toast_page, name="toast-page"),
    path("toast-region/", views.toast_region, name="toast-region"),
    path("toast/", views.toast, name="toast"),
    path("combobox/", views.combobox, name="combobox"),
    path("combobox/options/", views.combobox_options, name="combobox-options"),
    path("combobox/submit/", views.combobox_submit, name="combobox-submit"),
    path("table/", views.table, name="table"),
    path("search/", views.search, name="search"),
    path("upload/", views.upload, name="upload"),
]

templates/component_cookbook/partials/slide_over.html

Django template

{% extends "brickwork/components/_slide_over.html" %}{% load i18n %}
{% block title %}{% translate "Component details" %}{% endblock %}{% block body %}<p>{% translate "This is a package slide-over with a complete in-flow fallback page." %}</p>{% endblock %}{% block footer %}<footer class="bw-slide-over__footer"><a class="bw-btn bw-btn--secondary" href="{% url 'component_cookbook:index' %}">{% translate "Back to cookbook" %}</a></footer>{% endblock %}

templates/component_cookbook/slide_over.html

Django template

{% extends "component_cookbook/base.html" %}{% load i18n %}{% block heading %}{% translate "Slide-over" %}{% endblock %}{% block introduction %}{% translate "This full page is also the fallback when the overlay route is opened without HTMX." %}{% endblock %}{% block cookbook_content %}{% include "component_cookbook/partials/slide_over.html" %}{% endblock %}

templates/component_cookbook/base.html

Django template

{% extends "brickwork/shell/centred.html" %}
{% load static i18n %}
{% block page_title %}{% translate "Brickwork component cookbook" %}{% endblock %}
{% block head_js %}<script>window.BRICKWORK_JS_URL = "{% static 'brickwork/dist/brickwork.js' %}";</script>{% endblock %}
{% block body_js %}
<script src="{% static 'component_cookbook/vendor/htmx.min.js' %}"></script>
<script type="module">
import Alpine from "{% static 'component_cookbook/vendor/alpine.module.js' %}";
import focus from "{% static 'component_cookbook/vendor/focus.module.js' %}";
window.htmx.config.responseHandling = [{code: "204", swap: false}, {code: "422", swap: true}, {code: "[23]..", swap: true}, {code: "[45]..", swap: false, error: true}];
Alpine.plugin(focus);
const { registerBrickworkComponents } = await import(window.BRICKWORK_JS_URL);
registerBrickworkComponents(Alpine);
window.Alpine = Alpine;
Alpine.start();
</script>
{% endblock %}
{% block content %}
<header class="bw-stack bw-stack--sm"><p><a href="{% url 'component_cookbook:index' %}">{% translate "Component cookbook" %}</a></p><h1>{% block heading %}{% endblock %}</h1><p>{% block introduction %}{% endblock %}</p></header>
{% if messages %}{% for message in messages %}<p class="bw-alert bw-alert--success" role="status">{{ message }}</p>{% endfor %}{% endif %}
{% block cookbook_content %}{% endblock %}
{% endblock %}

templates/your_app/component.html

Django template

<a class="bw-btn bw-btn--secondary" href="{% url 'components:demo-slide-over' %}?title={{ title|urlencode }}&amp;placement={{ placement }}&amp;size={{ size }}&amp;backdrop_dismiss={{ backdrop_dismiss|yesno:'true,false' }}" hx-get="{% url 'components:demo-slide-over' %}?title={{ title|urlencode }}&amp;placement={{ placement }}&amp;size={{ size }}&amp;backdrop_dismiss={{ backdrop_dismiss|yesno:'true,false' }}" hx-target="#bw-slide-over-root" hx-swap="innerHTML">Open details</a>

your_app/views.py

Python

context = {'title': 'Component details', 'placement': 'end', 'size': 'md', 'backdrop_dismiss': True}

Guide

Add it to your project

  1. Extend `brickwork/components/_slide_over.html` and fill its public blocks.
  2. Use a real fallback link plus htmx to load it into the shell panel root.

View this component's source for the installed release

Options

Public API

Option Type Default Required Description
title string Not set Yes Accessible panel title unless title block overrides. Constraint: Must be non-empty when it supplies an accessible heading; a named title block may replace it.
placement string end No Logical panel edge. Allowed: start, end. Constraint: Use only one of the documented logical or physical placement choices.
size string md No Panel width scale. Allowed: sm, md, lg. Constraint: Allowed values: `sm`, `md`, `lg`; `full` is a modal-only size.
backdrop_dismiss boolean True No Whether backdrop dismisses. Constraint: False blocks backdrop dismissal only; Escape remains available by the dialog contract.
slide_over_id string bw-slide-over No Instance event identity. Constraint: Use a unique id-safe identity when more than one panel can be targeted.
close_href string Not set No Fallback close URL. Constraint: Use a real fallback destination so the no-JavaScript close control is never dead.
header_recipe string plain No Sticky header treatment. Allowed: plain, muted, bordered. Constraint: Allowed values: `plain`, `muted`, `bordered`.

Named blocks

Fill these only when you extend the component template. They are not values passed to an include or tag.

Block What it replaces
body Panel body markup, commonly a form; preserve the panel on a 422 invalid response. Constraint: Markup-only extension seam; passing a context variable with this name is ignored.

Behaviour

Accessibility and responsive behaviour

Accessibility
Dialog focus, Escape and focus return follow the modal contract.
Responsive
Logical start/end placement mirrors in RTL.
Without JavaScript
The fallback route displays the same panel in flow.

Need help?

Troubleshooting

Contract check: prefixed name (slide_over_title, slide_over_body, slide_over_footer)

Fill `title`, `body`, and `footer`. The removed prefixed names are silently discarded.

Documentation