django-advanced-menus 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. django_advanced_menus-1.0.0/LICENSE +21 -0
  2. django_advanced_menus-1.0.0/MANIFEST.in +2 -0
  3. django_advanced_menus-1.0.0/PKG-INFO +228 -0
  4. django_advanced_menus-1.0.0/README.md +201 -0
  5. django_advanced_menus-1.0.0/django_advanced_menus.egg-info/PKG-INFO +228 -0
  6. django_advanced_menus-1.0.0/django_advanced_menus.egg-info/SOURCES.txt +53 -0
  7. django_advanced_menus-1.0.0/django_advanced_menus.egg-info/dependency_links.txt +1 -0
  8. django_advanced_menus-1.0.0/django_advanced_menus.egg-info/requires.txt +1 -0
  9. django_advanced_menus-1.0.0/django_advanced_menus.egg-info/top_level.txt +1 -0
  10. django_advanced_menus-1.0.0/django_menus/__init__.py +2 -0
  11. django_advanced_menus-1.0.0/django_menus/apps.py +5 -0
  12. django_advanced_menus-1.0.0/django_menus/includes.py +53 -0
  13. django_advanced_menus-1.0.0/django_menus/menu/__init__.py +4 -0
  14. django_advanced_menus-1.0.0/django_menus/menu/context_menu.py +14 -0
  15. django_advanced_menus-1.0.0/django_menus/menu/menu.py +201 -0
  16. django_advanced_menus-1.0.0/django_menus/menu/menu_items.py +474 -0
  17. django_advanced_menus-1.0.0/django_menus/menu/tabs.py +84 -0
  18. django_advanced_menus-1.0.0/django_menus/packs.py +59 -0
  19. django_advanced_menus-1.0.0/django_menus/static/django_menus/django_menus.css +14 -0
  20. django_advanced_menus-1.0.0/django_menus/static/django_menus/django_menus.js +445 -0
  21. django_advanced_menus-1.0.0/django_menus/static/django_menus/popper/popper.min.js +6 -0
  22. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap4/ajax_dropdown.html +8 -0
  23. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap4/ajax_tooltip.html +7 -0
  24. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap4/badge.html +1 -0
  25. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap4/breadcrumb.html +20 -0
  26. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap4/button_group.html +13 -0
  27. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap4/button_menu.html +13 -0
  28. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap4/context_menu.html +14 -0
  29. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap4/divider.html +1 -0
  30. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap4/dropdown.html +20 -0
  31. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap4/header.html +1 -0
  32. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap4/main_menu.html +15 -0
  33. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap4/single_button.html +3 -0
  34. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap4/tab_menu.html +13 -0
  35. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap5/ajax_dropdown.html +8 -0
  36. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap5/ajax_tooltip.html +7 -0
  37. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap5/badge.html +1 -0
  38. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap5/breadcrumb.html +20 -0
  39. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap5/button_group.html +13 -0
  40. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap5/button_menu.html +13 -0
  41. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap5/context_menu.html +14 -0
  42. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap5/divider.html +1 -0
  43. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap5/dropdown.html +20 -0
  44. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap5/header.html +1 -0
  45. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap5/main_menu.html +15 -0
  46. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap5/single_button.html +3 -0
  47. django_advanced_menus-1.0.0/django_menus/templates/django_menus/bootstrap5/tab_menu.html +13 -0
  48. django_advanced_menus-1.0.0/django_menus/templates/django_menus/menu_key_press.html +23 -0
  49. django_advanced_menus-1.0.0/django_menus/templates/django_menus/script.html +7 -0
  50. django_advanced_menus-1.0.0/django_menus/templatetags/__init__.py +0 -0
  51. django_advanced_menus-1.0.0/django_menus/templatetags/django_menu_tags.py +34 -0
  52. django_advanced_menus-1.0.0/django_menus/test_settings.py +34 -0
  53. django_advanced_menus-1.0.0/django_menus/tests.py +88 -0
  54. django_advanced_menus-1.0.0/setup.cfg +4 -0
  55. django_advanced_menus-1.0.0/setup.py +25 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Ian Jones
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ recursive-include django_menus *
2
+ recursive-exclude django_menus *.pyc
@@ -0,0 +1,228 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-advanced-menus
3
+ Version: 1.0.0
4
+ Summary: Django app to render menus and load tabs with Ajax
5
+ Home-page: https://github.com/django-advance-utils/django-advanced-menus
6
+ Author: Ian Jones
7
+ Maintainer: Thomas Turner
8
+ Project-URL: Upstream, https://github.com/jonesim/django-menus
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.6
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: ajax-advanced-helpers>=1.0.1
16
+ Dynamic: author
17
+ Dynamic: classifier
18
+ Dynamic: description
19
+ Dynamic: description-content-type
20
+ Dynamic: home-page
21
+ Dynamic: license-file
22
+ Dynamic: maintainer
23
+ Dynamic: project-url
24
+ Dynamic: requires-dist
25
+ Dynamic: requires-python
26
+ Dynamic: summary
27
+
28
+ [![PyPI version](https://badge.fury.io/py/django-advanced-menus.svg)](https://badge.fury.io/py/django-advanced-menus)
29
+
30
+ # django-advanced-menus
31
+
32
+ Django app to render menus and load tabs with Ajax.
33
+
34
+ The [django-advance-utils](https://github.com/django-advance-utils) line of Ian Jones's
35
+ [django-tab-menus](https://github.com/jonesim/django-menus), forked so that releases can be cut as
36
+ the downstream libraries and django-advanced-report-builder need them. The Python package is still
37
+ `django_menus`, so existing imports and `INSTALLED_APPS` entries do not change; only the pip name
38
+ does. It depends on [ajax-advanced-helpers](https://github.com/django-advance-utils/ajax-advanced-helpers).
39
+
40
+ pip install django-advanced-menus
41
+
42
+ See example django project with docker compose file
43
+
44
+ Add to installed apps in settings
45
+ `'django_menus',`
46
+
47
+
48
+ ### Repeat clicks on menu links
49
+
50
+ Every menu item renders as an `<a href>`, and a menu item can point at a view that *does*
51
+ something rather than one that just shows a page. A double-click sends the URL twice, and a view
52
+ that resolves what to act on from its own current state - rather than from the state the link was
53
+ drawn against - acts on both.
54
+
55
+ This is **off by default** - swallowing a click is a behaviour change, and whether a project has
56
+ menu items pointing at views that mind being called twice is the project's business. Opt in with
57
+ the milliseconds to hold a link for, at whichever level fits. They cascade
58
+ **item -> menu -> page -> off**, so the narrowest one set wins:
59
+
60
+ # one item - usually the one that knows it minds being clicked twice
61
+ MenuItem('next_stage', 'Go to Next Stage', django_menus_repeat_click_ms=2000)
62
+
63
+ # every item in a menu that has not set its own
64
+ HtmlMenu(request, 'button_group', django_menus_repeat_click_ms=2000)
65
+
66
+ # a page, or a whole site if set on a base view class
67
+ class MyView(MenuTemplateView):
68
+ repeat_click_ms = 2000
69
+
70
+ A target view can also declare it, which is arguably the best place - the view that minds being
71
+ called twice is the thing that knows:
72
+
73
+ class MyView(View):
74
+ menu_config = {'attributes': {'data-django-menus-repeat-ms': 2000}}
75
+
76
+ That sits below the item and menu arguments, which are set at the call site and so win.
77
+
78
+ Because an item's own value wins, one item can be held on a page with the guard off, and
79
+ `django_menus_repeat_click_ms=0` on an item opts it out where the page has it on. A menu-level
80
+ value also reaches the items of a dropdown built on one of that menu's items.
81
+
82
+ The first two forms render a `data-django-menus-repeat-ms` attribute on the anchor and need
83
+ nothing else. The view attribute reaches the page through the `django_menus_script` context
84
+ variable, so output that once in a base template, alongside the include:
85
+
86
+ {% lib_include module='django_menus.includes' %}
87
+ {{ django_menus_script }}
88
+
89
+ All that does is set a JS window of the same name, which the guard reads on every click rather
90
+ than capturing at load - so it can also be set or changed directly, from a page's own script or a
91
+ console while diagnosing, on either side of the include:
92
+
93
+ django_menus_repeat_click_ms = 2000;
94
+
95
+ Anywhere it is set, `0` turns it off, and anything that is not a number above zero reads as off.
96
+
97
+ See the **Repeat Clicks** page in the example app for all three working side by side.
98
+
99
+ Once on, a second click on the same anchor inside that window is swallowed. Only real navigations
100
+ are affected - a `javascript:` href leaves the page in place and clicking again straight away is
101
+ often what the user means (close a modal, reopen it), so those are left alone, as are modified
102
+ clicks (ctrl/cmd/shift/alt, or any button but the primary) which open the link elsewhere.
103
+
104
+ Two limits worth knowing. The window collapses a double-click; it does not cover an impatient
105
+ re-click several seconds into a slow response, because holding a link until the page actually
106
+ goes away would strand anything that deliberately leaves the page up, like a download or a
107
+ `target="_blank"`. And the rule is about the href, not the effect: a `JAVASCRIPT` item is free to
108
+ set `window.location` itself, an `AJAX_BUTTON`'s view can answer with a redirect, and
109
+ `AJAX_GET_URL_NAME` fetches a view - those hit the view twice on a double-click just the same and
110
+ are out of scope here.
111
+
112
+ If you override these templates with your own copies, carry the `django-menus-item` class across
113
+ or those menus will not be covered.
114
+
115
+ ### Showing that the click registered
116
+
117
+ While a link is held it carries a `django-menus-clicked` class. Swallowing the second click stops
118
+ the duplicate request, but people double-click *because* the first click appeared to do nothing,
119
+ so the class is there to let you address the cause as well:
120
+
121
+ a.django-menus-clicked { opacity: .65; cursor: default; }
122
+
123
+ No styling is shipped for it, so it does nothing until a project adds a rule. Style the
124
+ appearance only - `pointer-events: none` looks like the obvious choice and lets the click fall
125
+ *through* to whatever sits underneath, which inside a dropdown is another menu item. The click is
126
+ already stopped in JS; the class only has to look the part.
127
+
128
+ The class is cleared when the hold expires, so a navigation that never arrives - cancelled, a
129
+ download, a `target="_blank"` - cannot leave a link looking permanently dead.
130
+
131
+ This is defence in depth, not a substitute for making such a view idempotent - the back button, a
132
+ refresh and a second tab all still send the request twice.
133
+
134
+ ## Bootstrap 4 and Bootstrap 5
135
+
136
+ Menus ships a template pack per Bootstrap version:
137
+
138
+ django_menus/templates/django_menus/bootstrap4/
139
+ django_menus/templates/django_menus/bootstrap5/
140
+
141
+ Pick one in settings. Bootstrap 4 is the default:
142
+
143
+ ```python
144
+ DJANGO_MENUS_TEMPLATE_PACK = 'bootstrap5'
145
+ ```
146
+
147
+ Each pack holds the complete set of menu templates and emits only its own version's names, so
148
+ the rendered page carries no classes the browser will ignore, and the two packs are free to
149
+ diverge structurally where Bootstrap 5 changed more than a name.
150
+
151
+ **Serving both from one deployment.** The setting may instead be a dotted path to a callable
152
+ taking the request and returning a pack name — a pack name never contains a dot, which is what
153
+ tells the two apart. The example app uses that for its nav bar toggle:
154
+
155
+ ```python
156
+ DJANGO_MENUS_TEMPLATE_PACK = 'menu_examples.context_processors.template_pack_for_request'
157
+ ```
158
+
159
+ **Overriding a template.** Menus no longer ships anything at the old flat paths, and the flat
160
+ path is tried *before* the pack. So a project that already overrides `django_menus/main_menu.html`
161
+ in its own templates directory keeps that override, and anything it does not override falls
162
+ through to the pack. One consequence worth knowing: an override is version-agnostic by
163
+ definition, so it applies to both packs.
164
+
165
+ **What a pack does not reach.** Two things, both deliberate:
166
+
167
+ - A tooltip's placement is a key in the attributes dict rather than markup, so its Bootstrap 5
168
+ rename lives in `django_menus/packs.py` as `PACK_ATTRIBUTES`. It is the only such entry.
169
+ - `css_classes`, `MenuItemDisplay`, the badge `css_class` and `attributes` are passed through
170
+ untouched, so those are yours to spell:
171
+
172
+ ```python
173
+ MenuItem('view1', 'Edit', css_classes=['btn-primary', 'me-1'])
174
+ ```
175
+
176
+ `menu_key_press.html` and `script.html` stay outside the packs — one is a keyboard handler and the
177
+ other sets the repeat-click window, neither is Bootstrap markup.
178
+
179
+ **Running the examples on either version.** The nav bar carries a `BS4 → BS5` toggle; it puts
180
+ `?bootstrap=5` on the URL and remembers the choice in the session, so a menu can be compared on
181
+ both without a restart. `MENUS_EXAMPLE_BOOTSTRAP=5` in the environment, or in settings, sets
182
+ where a fresh session starts.
183
+
184
+ Bootstrap 4 stays the default because the rest of the stack still emits it. On the Bootstrap 5
185
+ page the menus are correct and django-modals, show_src_code and crispy's template pack are
186
+ not — the example app makes that visible rather than hiding it, and it is the remaining work.
187
+
188
+ **Two settings, two paragraphs.** The template pack above is chosen by `DJANGO_MENUS_TEMPLATE_PACK`;
189
+ the scripts a page loads are chosen by the ecosystem-wide `CSS_FRAMEWORK` that ajax-helpers reads
190
+ (see the positioning section below). This paragraph describes Bootstrap 5 markup on a page whose
191
+ `CSS_FRAMEWORK` is still `bootstrap4`, so ajax-helpers still loads jQuery and Popper 1. With both
192
+ set to `bootstrap5`, ajax-helpers 1.0.1 loads neither and the menus include loads Popper 2 itself.
193
+
194
+ **The one load-order requirement.** jQuery must load before Bootstrap 5, which is what
195
+ `base.html` does. Bootstrap 5 dropped jQuery as a dependency and registers its plugin interface
196
+ only when jQuery got there first; without it `ajax_helpers.tooltip` quietly does nothing. The
197
+ same order keeps `window.Popper` pointing at the Popper 1 that ajax_helpers ships, which is what
198
+ the dropdown positioning is written against — Bootstrap 5's bundle keeps its own Popper 2
199
+ private. `django_menus.js` detects Popper 2 anyway, for a project that brings its own.
200
+
201
+ **One template this does not reach.** `ajax_tooltip.html` passes a Bootstrap-classed template to
202
+ `ajax_helpers.tooltip`, and ajax_helpers 1.0.0 rewrote that function around its own `ah-`
203
+ classes — it reads `.ah-tooltip-inner` out of whatever template it is handed. That template no
204
+ longer matches on either Bootstrap version, independently of anything here. No example view
205
+ exercises it. The fix is to stop passing a Bootstrap template and let ajax_helpers use its own,
206
+ which is version-neutral; it is left alone here because it is not a Bootstrap 5 question.
207
+
208
+ **Keeping the packs in step.** Two folders means a fix can land in one and not the other, and
209
+ unlike a missing class name that is invisible on whichever version you are not looking at. So
210
+ `menu_examples/tests/test_template_packs.py` normalises every Bootstrap 4 name in a pack4
211
+ template to its Bootstrap 5 spelling and requires the result to equal the pack5 file exactly.
212
+ A deliberate divergence goes in `STRUCTURAL_DIVERGENCE` with a note, which is the point at
213
+ which someone has to think about it. It is empty today: every difference is still a rename.
214
+
215
+ ## Bootstrap 4 and Bootstrap 5: positioning the dropdowns
216
+
217
+ Dropdown menus are positioned with Popper. Bootstrap 4 puts Popper 1 on the page as a global
218
+ constructor, and that is what the menus have always used. Bootstrap 5 bundles Popper 2 privately
219
+ and leaves `window.Popper` unset, so under Bootstrap 5 the default include loads `@popperjs/core`
220
+ itself (vendored, with a jsDelivr fallback) and the script detects whichever generation it finds.
221
+ The switch is the ecosystem-wide `CSS_FRAMEWORK` setting that ajax-helpers reads:
222
+
223
+ ```python
224
+ CSS_FRAMEWORK = 'bootstrap5' # default 'bootstrap4'
225
+ ```
226
+
227
+ Nothing changes under Bootstrap 4. With no Popper on the page at all, a menu opens straight
228
+ below its button.
@@ -0,0 +1,201 @@
1
+ [![PyPI version](https://badge.fury.io/py/django-advanced-menus.svg)](https://badge.fury.io/py/django-advanced-menus)
2
+
3
+ # django-advanced-menus
4
+
5
+ Django app to render menus and load tabs with Ajax.
6
+
7
+ The [django-advance-utils](https://github.com/django-advance-utils) line of Ian Jones's
8
+ [django-tab-menus](https://github.com/jonesim/django-menus), forked so that releases can be cut as
9
+ the downstream libraries and django-advanced-report-builder need them. The Python package is still
10
+ `django_menus`, so existing imports and `INSTALLED_APPS` entries do not change; only the pip name
11
+ does. It depends on [ajax-advanced-helpers](https://github.com/django-advance-utils/ajax-advanced-helpers).
12
+
13
+ pip install django-advanced-menus
14
+
15
+ See example django project with docker compose file
16
+
17
+ Add to installed apps in settings
18
+ `'django_menus',`
19
+
20
+
21
+ ### Repeat clicks on menu links
22
+
23
+ Every menu item renders as an `<a href>`, and a menu item can point at a view that *does*
24
+ something rather than one that just shows a page. A double-click sends the URL twice, and a view
25
+ that resolves what to act on from its own current state - rather than from the state the link was
26
+ drawn against - acts on both.
27
+
28
+ This is **off by default** - swallowing a click is a behaviour change, and whether a project has
29
+ menu items pointing at views that mind being called twice is the project's business. Opt in with
30
+ the milliseconds to hold a link for, at whichever level fits. They cascade
31
+ **item -> menu -> page -> off**, so the narrowest one set wins:
32
+
33
+ # one item - usually the one that knows it minds being clicked twice
34
+ MenuItem('next_stage', 'Go to Next Stage', django_menus_repeat_click_ms=2000)
35
+
36
+ # every item in a menu that has not set its own
37
+ HtmlMenu(request, 'button_group', django_menus_repeat_click_ms=2000)
38
+
39
+ # a page, or a whole site if set on a base view class
40
+ class MyView(MenuTemplateView):
41
+ repeat_click_ms = 2000
42
+
43
+ A target view can also declare it, which is arguably the best place - the view that minds being
44
+ called twice is the thing that knows:
45
+
46
+ class MyView(View):
47
+ menu_config = {'attributes': {'data-django-menus-repeat-ms': 2000}}
48
+
49
+ That sits below the item and menu arguments, which are set at the call site and so win.
50
+
51
+ Because an item's own value wins, one item can be held on a page with the guard off, and
52
+ `django_menus_repeat_click_ms=0` on an item opts it out where the page has it on. A menu-level
53
+ value also reaches the items of a dropdown built on one of that menu's items.
54
+
55
+ The first two forms render a `data-django-menus-repeat-ms` attribute on the anchor and need
56
+ nothing else. The view attribute reaches the page through the `django_menus_script` context
57
+ variable, so output that once in a base template, alongside the include:
58
+
59
+ {% lib_include module='django_menus.includes' %}
60
+ {{ django_menus_script }}
61
+
62
+ All that does is set a JS window of the same name, which the guard reads on every click rather
63
+ than capturing at load - so it can also be set or changed directly, from a page's own script or a
64
+ console while diagnosing, on either side of the include:
65
+
66
+ django_menus_repeat_click_ms = 2000;
67
+
68
+ Anywhere it is set, `0` turns it off, and anything that is not a number above zero reads as off.
69
+
70
+ See the **Repeat Clicks** page in the example app for all three working side by side.
71
+
72
+ Once on, a second click on the same anchor inside that window is swallowed. Only real navigations
73
+ are affected - a `javascript:` href leaves the page in place and clicking again straight away is
74
+ often what the user means (close a modal, reopen it), so those are left alone, as are modified
75
+ clicks (ctrl/cmd/shift/alt, or any button but the primary) which open the link elsewhere.
76
+
77
+ Two limits worth knowing. The window collapses a double-click; it does not cover an impatient
78
+ re-click several seconds into a slow response, because holding a link until the page actually
79
+ goes away would strand anything that deliberately leaves the page up, like a download or a
80
+ `target="_blank"`. And the rule is about the href, not the effect: a `JAVASCRIPT` item is free to
81
+ set `window.location` itself, an `AJAX_BUTTON`'s view can answer with a redirect, and
82
+ `AJAX_GET_URL_NAME` fetches a view - those hit the view twice on a double-click just the same and
83
+ are out of scope here.
84
+
85
+ If you override these templates with your own copies, carry the `django-menus-item` class across
86
+ or those menus will not be covered.
87
+
88
+ ### Showing that the click registered
89
+
90
+ While a link is held it carries a `django-menus-clicked` class. Swallowing the second click stops
91
+ the duplicate request, but people double-click *because* the first click appeared to do nothing,
92
+ so the class is there to let you address the cause as well:
93
+
94
+ a.django-menus-clicked { opacity: .65; cursor: default; }
95
+
96
+ No styling is shipped for it, so it does nothing until a project adds a rule. Style the
97
+ appearance only - `pointer-events: none` looks like the obvious choice and lets the click fall
98
+ *through* to whatever sits underneath, which inside a dropdown is another menu item. The click is
99
+ already stopped in JS; the class only has to look the part.
100
+
101
+ The class is cleared when the hold expires, so a navigation that never arrives - cancelled, a
102
+ download, a `target="_blank"` - cannot leave a link looking permanently dead.
103
+
104
+ This is defence in depth, not a substitute for making such a view idempotent - the back button, a
105
+ refresh and a second tab all still send the request twice.
106
+
107
+ ## Bootstrap 4 and Bootstrap 5
108
+
109
+ Menus ships a template pack per Bootstrap version:
110
+
111
+ django_menus/templates/django_menus/bootstrap4/
112
+ django_menus/templates/django_menus/bootstrap5/
113
+
114
+ Pick one in settings. Bootstrap 4 is the default:
115
+
116
+ ```python
117
+ DJANGO_MENUS_TEMPLATE_PACK = 'bootstrap5'
118
+ ```
119
+
120
+ Each pack holds the complete set of menu templates and emits only its own version's names, so
121
+ the rendered page carries no classes the browser will ignore, and the two packs are free to
122
+ diverge structurally where Bootstrap 5 changed more than a name.
123
+
124
+ **Serving both from one deployment.** The setting may instead be a dotted path to a callable
125
+ taking the request and returning a pack name — a pack name never contains a dot, which is what
126
+ tells the two apart. The example app uses that for its nav bar toggle:
127
+
128
+ ```python
129
+ DJANGO_MENUS_TEMPLATE_PACK = 'menu_examples.context_processors.template_pack_for_request'
130
+ ```
131
+
132
+ **Overriding a template.** Menus no longer ships anything at the old flat paths, and the flat
133
+ path is tried *before* the pack. So a project that already overrides `django_menus/main_menu.html`
134
+ in its own templates directory keeps that override, and anything it does not override falls
135
+ through to the pack. One consequence worth knowing: an override is version-agnostic by
136
+ definition, so it applies to both packs.
137
+
138
+ **What a pack does not reach.** Two things, both deliberate:
139
+
140
+ - A tooltip's placement is a key in the attributes dict rather than markup, so its Bootstrap 5
141
+ rename lives in `django_menus/packs.py` as `PACK_ATTRIBUTES`. It is the only such entry.
142
+ - `css_classes`, `MenuItemDisplay`, the badge `css_class` and `attributes` are passed through
143
+ untouched, so those are yours to spell:
144
+
145
+ ```python
146
+ MenuItem('view1', 'Edit', css_classes=['btn-primary', 'me-1'])
147
+ ```
148
+
149
+ `menu_key_press.html` and `script.html` stay outside the packs — one is a keyboard handler and the
150
+ other sets the repeat-click window, neither is Bootstrap markup.
151
+
152
+ **Running the examples on either version.** The nav bar carries a `BS4 → BS5` toggle; it puts
153
+ `?bootstrap=5` on the URL and remembers the choice in the session, so a menu can be compared on
154
+ both without a restart. `MENUS_EXAMPLE_BOOTSTRAP=5` in the environment, or in settings, sets
155
+ where a fresh session starts.
156
+
157
+ Bootstrap 4 stays the default because the rest of the stack still emits it. On the Bootstrap 5
158
+ page the menus are correct and django-modals, show_src_code and crispy's template pack are
159
+ not — the example app makes that visible rather than hiding it, and it is the remaining work.
160
+
161
+ **Two settings, two paragraphs.** The template pack above is chosen by `DJANGO_MENUS_TEMPLATE_PACK`;
162
+ the scripts a page loads are chosen by the ecosystem-wide `CSS_FRAMEWORK` that ajax-helpers reads
163
+ (see the positioning section below). This paragraph describes Bootstrap 5 markup on a page whose
164
+ `CSS_FRAMEWORK` is still `bootstrap4`, so ajax-helpers still loads jQuery and Popper 1. With both
165
+ set to `bootstrap5`, ajax-helpers 1.0.1 loads neither and the menus include loads Popper 2 itself.
166
+
167
+ **The one load-order requirement.** jQuery must load before Bootstrap 5, which is what
168
+ `base.html` does. Bootstrap 5 dropped jQuery as a dependency and registers its plugin interface
169
+ only when jQuery got there first; without it `ajax_helpers.tooltip` quietly does nothing. The
170
+ same order keeps `window.Popper` pointing at the Popper 1 that ajax_helpers ships, which is what
171
+ the dropdown positioning is written against — Bootstrap 5's bundle keeps its own Popper 2
172
+ private. `django_menus.js` detects Popper 2 anyway, for a project that brings its own.
173
+
174
+ **One template this does not reach.** `ajax_tooltip.html` passes a Bootstrap-classed template to
175
+ `ajax_helpers.tooltip`, and ajax_helpers 1.0.0 rewrote that function around its own `ah-`
176
+ classes — it reads `.ah-tooltip-inner` out of whatever template it is handed. That template no
177
+ longer matches on either Bootstrap version, independently of anything here. No example view
178
+ exercises it. The fix is to stop passing a Bootstrap template and let ajax_helpers use its own,
179
+ which is version-neutral; it is left alone here because it is not a Bootstrap 5 question.
180
+
181
+ **Keeping the packs in step.** Two folders means a fix can land in one and not the other, and
182
+ unlike a missing class name that is invisible on whichever version you are not looking at. So
183
+ `menu_examples/tests/test_template_packs.py` normalises every Bootstrap 4 name in a pack4
184
+ template to its Bootstrap 5 spelling and requires the result to equal the pack5 file exactly.
185
+ A deliberate divergence goes in `STRUCTURAL_DIVERGENCE` with a note, which is the point at
186
+ which someone has to think about it. It is empty today: every difference is still a rename.
187
+
188
+ ## Bootstrap 4 and Bootstrap 5: positioning the dropdowns
189
+
190
+ Dropdown menus are positioned with Popper. Bootstrap 4 puts Popper 1 on the page as a global
191
+ constructor, and that is what the menus have always used. Bootstrap 5 bundles Popper 2 privately
192
+ and leaves `window.Popper` unset, so under Bootstrap 5 the default include loads `@popperjs/core`
193
+ itself (vendored, with a jsDelivr fallback) and the script detects whichever generation it finds.
194
+ The switch is the ecosystem-wide `CSS_FRAMEWORK` setting that ajax-helpers reads:
195
+
196
+ ```python
197
+ CSS_FRAMEWORK = 'bootstrap5' # default 'bootstrap4'
198
+ ```
199
+
200
+ Nothing changes under Bootstrap 4. With no Popper on the page at all, a menu opens straight
201
+ below its button.