django-bootstrap6-admin 0.1.1__tar.gz → 0.2.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 (72) hide show
  1. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/CHANGELOG.md +25 -0
  2. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/CONTRIBUTING.md +2 -0
  3. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/MANIFEST.in +1 -0
  4. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/PKG-INFO +12 -2
  5. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/README.md +11 -1
  6. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/__init__.py +1 -1
  7. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/static/django_bootstrap6_admin/css/admin.css +45 -1
  8. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/static/django_bootstrap6_admin/js/admin.js +98 -6
  9. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/static/django_bootstrap6_admin/js/theme.js +17 -5
  10. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin.egg-info/PKG-INFO +12 -2
  11. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin.egg-info/SOURCES.txt +1 -0
  12. django_bootstrap6_admin-0.2.0/tests/js/theme_toggle.test.js +141 -0
  13. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/tests/test_admin_ui.py +11 -1
  14. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/tests/test_package.py +16 -0
  15. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/LICENSE +0 -0
  16. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/NOTICE +0 -0
  17. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/pyproject.toml +0 -0
  18. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/setup.cfg +0 -0
  19. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/apps.py +0 -0
  20. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/static/django_bootstrap6_admin/css/widgets-overrides.css +0 -0
  21. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/static/django_bootstrap6_admin/vendor/bootstrap/LICENSE +0 -0
  22. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/static/django_bootstrap6_admin/vendor/bootstrap/css/bootstrap.min.css +0 -0
  23. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/static/django_bootstrap6_admin/vendor/bootstrap/js/bootstrap.bundle.min.js +0 -0
  24. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/404.html +0 -0
  25. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/500.html +0 -0
  26. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/actions.html +0 -0
  27. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/app_index.html +0 -0
  28. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/app_list.html +0 -0
  29. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/auth/user/add_form.html +0 -0
  30. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/auth/user/change_password.html +0 -0
  31. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/base.html +0 -0
  32. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/base_site.html +0 -0
  33. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/change_form.html +0 -0
  34. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/change_form_object_tools.html +0 -0
  35. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/change_list.html +0 -0
  36. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/change_list_object_tools.html +0 -0
  37. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/change_list_results.html +0 -0
  38. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/color_theme_toggle.html +0 -0
  39. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/date_hierarchy.html +0 -0
  40. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/delete_confirmation.html +0 -0
  41. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/delete_selected_confirmation.html +0 -0
  42. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/edit_inline/stacked.html +0 -0
  43. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/edit_inline/tabular.html +0 -0
  44. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/filter.html +0 -0
  45. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/includes/fieldset.html +0 -0
  46. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/includes/object_delete_summary.html +0 -0
  47. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/index.html +0 -0
  48. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/login.html +0 -0
  49. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/nav_sidebar.html +0 -0
  50. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/object_history.html +0 -0
  51. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/pagination.html +0 -0
  52. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/search_form.html +0 -0
  53. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/submit_line.html +0 -0
  54. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/widgets/foreign_key_raw_id.html +0 -0
  55. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/admin/widgets/related_widget_wrapper.html +0 -0
  56. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/registration/logged_out.html +0 -0
  57. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/registration/password_change_done.html +0 -0
  58. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templates/registration/password_change_form.html +0 -0
  59. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templatetags/__init__.py +0 -0
  60. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin/templatetags/bootstrap6_admin.py +0 -0
  61. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin.egg-info/dependency_links.txt +0 -0
  62. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin.egg-info/requires.txt +0 -0
  63. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/src/django_bootstrap6_admin.egg-info/top_level.txt +0 -0
  64. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/tests/__init__.py +0 -0
  65. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/tests/settings.py +0 -0
  66. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/tests/smoke_wheel.py +0 -0
  67. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/tests/test_templatetags.py +0 -0
  68. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/tests/testapp/__init__.py +0 -0
  69. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/tests/testapp/admin.py +0 -0
  70. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/tests/testapp/apps.py +0 -0
  71. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/tests/testapp/models.py +0 -0
  72. {django_bootstrap6_admin-0.1.1 → django_bootstrap6_admin-0.2.0}/tests/urls.py +0 -0
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-11-15
11
+
12
+ ### Added
13
+
14
+ - The desktop filter panel can now be collapsed and expanded, and the
15
+ collapsed state is remembered between changelist pages.
16
+ - The "Show counts" / "Hide counts" choice is remembered across changelist
17
+ pages instead of resetting on navigation.
18
+ - Subtle vertical rules separate columns in changelists, inline tables and the
19
+ object history table.
20
+
21
+ ### Fixed
22
+
23
+ - Removed the duplicate native dropdown arrow on the bulk-action select.
24
+ - Improved dark-theme text contrast for secondary labels, table headers,
25
+ filter links, help text and links.
26
+
27
+ ## [0.1.2] - 2026-11-15
28
+
29
+ ### Fixed
30
+
31
+ - Clicking the theme toggle in a dark system no longer moves to an `auto`
32
+ state that resolves to dark and leaves the page unchanged. The toggle skips
33
+ that no-op step, so a single click always changes the visible theme.
34
+
10
35
  ## [0.1.1] - 2026-11-15
11
36
 
12
37
  ### Added
@@ -23,6 +23,8 @@ python -m django test --settings=tests.settings
23
23
  Run the linter and formatter check before opening a pull request:
24
24
 
25
25
  ```bash
26
+ python -m django test --settings=tests.settings
27
+ node tests/js/theme_toggle.test.js
26
28
  ruff check .
27
29
  ruff format --check .
28
30
  ```
@@ -6,5 +6,6 @@ include CONTRIBUTING.md
6
6
  recursive-include src/django_bootstrap6_admin/templates *.html
7
7
  recursive-include src/django_bootstrap6_admin/static *
8
8
  recursive-include tests *.py
9
+ recursive-include tests *.js
9
10
  global-exclude __pycache__
10
11
  global-exclude *.py[cod]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-bootstrap6-admin
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: A modern Bootstrap 6 skin for the Django admin.
5
5
  Author-email: Aliberk Sandıkçı <git@asandikci.com>
6
6
  Maintainer-email: Aliberk Sandıkçı <git@asandikci.com>
@@ -57,14 +57,23 @@ permissions and JavaScript remain in charge.
57
57
  with a backdrop (mobile).
58
58
  - Dashboard with application cards, model shortcuts and recent activity.
59
59
  - Changelist with search, date hierarchy, collapsible filters, sortable
60
- headers, bulk actions and Bootstrap pagination.
60
+ headers, bulk actions and Bootstrap pagination. The desktop filter panel can
61
+ be collapsed, and both its state and the "Show counts" choice are remembered
62
+ between pages.
63
+ - Subtle vertical rules separate columns in changelists, inline tables and the
64
+ object history.
61
65
  - Change form with card-based fieldsets, collapsible sections, a clear save
62
66
  bar, tabular/stacked inlines and related-object popups.
63
67
  - Modern login, logout and password change pages.
64
68
  - Light, dark and system themes. The existing Django `theme` localStorage
65
69
  preference is synchronised with Bootstrap's `data-bs-theme` attribute.
70
+ - Dark-theme secondary text, table headers, filter links and help text use
71
+ higher-contrast tokens for better readability.
66
72
  - Soft fade-in/fade-out transition when switching themes, skipped
67
73
  automatically for users who prefer reduced motion.
74
+ - Every click changes the visible theme: if `auto` would resolve to the theme
75
+ already on screen, the toggle skips that step instead of appearing to do
76
+ nothing.
68
77
  - Accessible focus states, labels, table headers and ARIA attributes.
69
78
  - All Django admin JavaScript hooks are preserved: `actions.js`,
70
79
  `filters.js`, `nav_sidebar.js`, inlines, Select2/autocomplete, calendar
@@ -236,6 +245,7 @@ Run the tests and the linter:
236
245
 
237
246
  ```bash
238
247
  python -m django test --settings=tests.settings
248
+ node tests/js/theme_toggle.test.js
239
249
  ruff check .
240
250
  ruff format --check .
241
251
  ```
@@ -20,14 +20,23 @@ permissions and JavaScript remain in charge.
20
20
  with a backdrop (mobile).
21
21
  - Dashboard with application cards, model shortcuts and recent activity.
22
22
  - Changelist with search, date hierarchy, collapsible filters, sortable
23
- headers, bulk actions and Bootstrap pagination.
23
+ headers, bulk actions and Bootstrap pagination. The desktop filter panel can
24
+ be collapsed, and both its state and the "Show counts" choice are remembered
25
+ between pages.
26
+ - Subtle vertical rules separate columns in changelists, inline tables and the
27
+ object history.
24
28
  - Change form with card-based fieldsets, collapsible sections, a clear save
25
29
  bar, tabular/stacked inlines and related-object popups.
26
30
  - Modern login, logout and password change pages.
27
31
  - Light, dark and system themes. The existing Django `theme` localStorage
28
32
  preference is synchronised with Bootstrap's `data-bs-theme` attribute.
33
+ - Dark-theme secondary text, table headers, filter links and help text use
34
+ higher-contrast tokens for better readability.
29
35
  - Soft fade-in/fade-out transition when switching themes, skipped
30
36
  automatically for users who prefer reduced motion.
37
+ - Every click changes the visible theme: if `auto` would resolve to the theme
38
+ already on screen, the toggle skips that step instead of appearing to do
39
+ nothing.
31
40
  - Accessible focus states, labels, table headers and ARIA attributes.
32
41
  - All Django admin JavaScript hooks are preserved: `actions.js`,
33
42
  `filters.js`, `nav_sidebar.js`, inlines, Select2/autocomplete, calendar
@@ -199,6 +208,7 @@ Run the tests and the linter:
199
208
 
200
209
  ```bash
201
210
  python -m django test --settings=tests.settings
211
+ node tests/js/theme_toggle.test.js
202
212
  ruff check .
203
213
  ruff format --check .
204
214
  ```
@@ -1,3 +1,3 @@
1
1
  """Bootstrap 6 skin for the Django admin."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.2.0"
@@ -111,6 +111,23 @@
111
111
  2. Base
112
112
  -------------------------------------------------------------------------- */
113
113
 
114
+ /*
115
+ * Bootstrap's dark gray-500/600 secondary text is too dim on the deep navy
116
+ * admin surfaces, especially at the small sizes used by filters, table
117
+ * headers and action help. Lift those tokens for the effective dark theme.
118
+ */
119
+ [data-bs-theme="dark"],
120
+ [data-effective-theme="dark"] {
121
+ --bs-fg-1: #f1f5f9;
122
+ --bs-fg-2: #d7e0ea;
123
+ --bs-fg-3: #b9c7d6;
124
+ --bs-fg-4: #9fb1c4;
125
+ --ba-text-muted: #c9d4e0;
126
+ --bs-link-color: #8dbcf7;
127
+ --bs-link-hover-color: #b7d4ff;
128
+ --bs-border-muted: #34465a;
129
+ }
130
+
114
131
  html {
115
132
  min-height: 100%;
116
133
  }
@@ -1365,7 +1382,7 @@ a.ba-timeline-object:focus {
1365
1382
  }
1366
1383
 
1367
1384
  .ba-filters-toggle-row {
1368
- display: none;
1385
+ display: flex;
1369
1386
  }
1370
1387
 
1371
1388
  .ba-filters-toggle {
@@ -1551,6 +1568,20 @@ a.ba-date-chip:focus {
1551
1568
  border-block-end: 0;
1552
1569
  }
1553
1570
 
1571
+ /* Very light column rules keep dense admin tables readable without making
1572
+ * every cell look boxed in. */
1573
+ #result_list thead th:not(:last-child),
1574
+ #result_list tbody th:not(:last-child),
1575
+ #result_list tbody td:not(:last-child),
1576
+ .ba-inline-table thead th:not(:last-child),
1577
+ .ba-inline-table tbody th:not(:last-child),
1578
+ .ba-inline-table tbody td:not(:last-child),
1579
+ .ba-history-table thead th:not(:last-child),
1580
+ .ba-history-table tbody th:not(:last-child),
1581
+ .ba-history-table tbody td:not(:last-child) {
1582
+ border-inline-end: 1px solid var(--bs-border-muted);
1583
+ }
1584
+
1554
1585
  #result_list tbody tr.selected > * {
1555
1586
  background: var(--bs-primary-bg-subtle) !important;
1556
1587
  }
@@ -1597,6 +1628,7 @@ a.ba-date-chip:focus {
1597
1628
 
1598
1629
  #result_list select {
1599
1630
  padding-inline-end: 1.6rem;
1631
+ appearance: none;
1600
1632
  background-image: var(--ba-select-arrow);
1601
1633
  background-position: right 0.45rem center;
1602
1634
  background-repeat: no-repeat;
@@ -2177,6 +2209,7 @@ label.required::after {
2177
2209
  .ba-form-group select,
2178
2210
  .ba-actions select {
2179
2211
  padding-inline-end: 2rem;
2212
+ appearance: none;
2180
2213
  background-image: var(--ba-select-arrow);
2181
2214
  background-position: right 0.7rem center;
2182
2215
  background-repeat: no-repeat;
@@ -2551,6 +2584,7 @@ div.help ul {
2551
2584
 
2552
2585
  .ba-inline-table select {
2553
2586
  padding-inline-end: 1.6rem;
2587
+ appearance: none;
2554
2588
  background-image: var(--ba-select-arrow);
2555
2589
  background-position: right 0.45rem center;
2556
2590
  background-repeat: no-repeat;
@@ -3202,6 +3236,16 @@ body.popup .ba-submit-row {
3202
3236
  13. Responsive
3203
3237
  -------------------------------------------------------------------------- */
3204
3238
 
3239
+ @media (min-width: 1200px) {
3240
+ #changelist.ba-filters-collapsed .ba-changelist-layout {
3241
+ grid-template-columns: minmax(0, 1fr);
3242
+ }
3243
+
3244
+ #changelist.ba-filters-collapsed .ba-filters {
3245
+ display: none;
3246
+ }
3247
+ }
3248
+
3205
3249
  @media (max-width: 1199.98px) {
3206
3250
  .ba-dashboard {
3207
3251
  grid-template-columns: minmax(0, 1fr);
@@ -10,7 +10,10 @@
10
10
  */
11
11
  (() => {
12
12
  const MOBILE = window.matchMedia("(max-width: 1023.98px)");
13
+ const DESKTOP = window.matchMedia("(min-width: 1200px)");
13
14
  const STORAGE_KEY = "django.admin.navSidebarIsOpen";
15
+ const FILTERS_KEY = "django.admin.filtersCollapsed";
16
+ const FACETS_KEY = "django.admin.showFacets";
14
17
 
15
18
  function initSidebar() {
16
19
  const main = document.getElementById("main");
@@ -228,16 +231,50 @@
228
231
  return;
229
232
  }
230
233
 
234
+ const isCollapsed = () =>
235
+ changelist.classList.contains("ba-filters-collapsed");
236
+
231
237
  const sync = () => {
232
- toggle.setAttribute(
233
- "aria-expanded",
234
- changelist.classList.contains("ba-filters-open")
235
- ? "true"
236
- : "false",
237
- );
238
+ const expanded = DESKTOP.matches
239
+ ? !isCollapsed()
240
+ : changelist.classList.contains("ba-filters-open");
241
+ toggle.setAttribute("aria-expanded", expanded ? "true" : "false");
242
+ if (panel) {
243
+ panel.toggleAttribute(
244
+ "inert",
245
+ DESKTOP.matches && isCollapsed(),
246
+ );
247
+ }
238
248
  };
239
249
 
250
+ const setCollapsed = (collapsed) => {
251
+ changelist.classList.toggle("ba-filters-collapsed", collapsed);
252
+ if (DESKTOP.matches) {
253
+ localStorage.setItem(
254
+ FILTERS_KEY,
255
+ collapsed ? "true" : "false",
256
+ );
257
+ }
258
+ sync();
259
+ };
260
+
261
+ // Restore the desktop preference. On mobile the panel is a drawer and
262
+ // the collapsed class has no visual effect.
263
+ if (DESKTOP.matches && localStorage.getItem(FILTERS_KEY) === "true") {
264
+ changelist.classList.add("ba-filters-collapsed");
265
+ }
266
+
240
267
  toggle.addEventListener("click", () => {
268
+ if (DESKTOP.matches) {
269
+ const collapsed = !isCollapsed();
270
+ setCollapsed(collapsed);
271
+ if (!collapsed) {
272
+ const target = panel?.querySelector("summary, a, button");
273
+ window.requestAnimationFrame(() => target?.focus());
274
+ }
275
+ return;
276
+ }
277
+
241
278
  changelist.classList.toggle("ba-filters-open");
242
279
  sync();
243
280
  if (changelist.classList.contains("ba-filters-open")) {
@@ -249,15 +286,69 @@
249
286
 
250
287
  if (close) {
251
288
  close.addEventListener("click", () => {
289
+ setCollapsed(true);
252
290
  changelist.classList.remove("ba-filters-open");
253
291
  sync();
254
292
  toggle.focus();
255
293
  });
256
294
  }
257
295
 
296
+ if (DESKTOP.addEventListener) {
297
+ DESKTOP.addEventListener("change", () => {
298
+ if (DESKTOP.matches) {
299
+ changelist.classList.toggle(
300
+ "ba-filters-collapsed",
301
+ localStorage.getItem(FILTERS_KEY) === "true",
302
+ );
303
+ changelist.classList.remove("ba-filters-open");
304
+ }
305
+ sync();
306
+ });
307
+ }
308
+
258
309
  sync();
259
310
  }
260
311
 
312
+ function initFacetPreference() {
313
+ const panel = document.getElementById("changelist-filter");
314
+ if (!panel) {
315
+ return;
316
+ }
317
+
318
+ const links = document.querySelectorAll(
319
+ ".ba-filters-actions .hidelink, .ba-filters-actions .viewlink",
320
+ );
321
+ if (!links.length) {
322
+ return;
323
+ }
324
+
325
+ links.forEach((link) => {
326
+ link.addEventListener("click", () => {
327
+ const show = link.classList.contains("viewlink");
328
+ localStorage.setItem(FACETS_KEY, show ? "true" : "false");
329
+ });
330
+ });
331
+
332
+ const stored = localStorage.getItem(FACETS_KEY);
333
+ if (stored !== "true" && stored !== "false") {
334
+ return;
335
+ }
336
+
337
+ const url = new URL(window.location.href);
338
+ const hasFacets = url.searchParams.has("_facets");
339
+ const showFacets = stored === "true";
340
+ if (hasFacets === showFacets) {
341
+ return;
342
+ }
343
+
344
+ if (showFacets) {
345
+ url.searchParams.set("_facets", "1");
346
+ } else {
347
+ url.searchParams.delete("_facets");
348
+ }
349
+ window.location.replace(url.toString());
350
+ }
351
+
261
352
  function initDirtyGuard() {
262
353
  const form =
263
354
  document.querySelector(".ba-change-form form") ||
@@ -327,6 +418,7 @@
327
418
  function initAll() {
328
419
  initSidebar();
329
420
  initFilterDisclosure();
421
+ initFacetPreference();
330
422
  initDirtyGuard();
331
423
  initErrorFocus();
332
424
  initAccessibility();
@@ -24,9 +24,10 @@
24
24
  if (modes.includes(stored)) {
25
25
  return stored;
26
26
  }
27
- // First visit: start in the effective OS theme so the visible page
28
- // and the toggle icon agree immediately. Auto remains in the cycle.
29
- return media.matches ? "dark" : "light";
27
+ // No explicit preference yet: follow the system. Keeping the mode as
28
+ // "auto" prevents the first click from landing on an auto state that
29
+ // resolves to the same theme.
30
+ return "auto";
30
31
  }
31
32
 
32
33
  function updateButtons(mode) {
@@ -43,11 +44,15 @@
43
44
  });
44
45
  }
45
46
 
47
+ function effectiveMode(mode) {
48
+ mode = normalize(mode);
49
+ return mode === "auto" ? (media.matches ? "dark" : "light") : mode;
50
+ }
51
+
46
52
  function apply(mode) {
47
53
  mode = normalize(mode);
48
54
  const root = document.documentElement;
49
- const effective =
50
- mode === "auto" ? (media.matches ? "dark" : "light") : mode;
55
+ const effective = effectiveMode(mode);
51
56
 
52
57
  root.dataset.theme = mode;
53
58
  root.dataset.effectiveTheme = effective;
@@ -121,6 +126,7 @@
121
126
 
122
127
  function cycle() {
123
128
  const mode = current();
129
+ const effective = effectiveMode(mode);
124
130
  let next;
125
131
 
126
132
  if (media.matches) {
@@ -131,6 +137,12 @@
131
137
  mode === "auto" ? "dark" : mode === "dark" ? "light" : "auto";
132
138
  }
133
139
 
140
+ // In dark systems auto is dark, and in light systems auto is light.
141
+ // Skip that no-op step so every click visibly changes the theme.
142
+ if (effectiveMode(next) === effective) {
143
+ next = effective === "dark" ? "light" : "dark";
144
+ }
145
+
134
146
  set(next);
135
147
  }
136
148
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-bootstrap6-admin
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: A modern Bootstrap 6 skin for the Django admin.
5
5
  Author-email: Aliberk Sandıkçı <git@asandikci.com>
6
6
  Maintainer-email: Aliberk Sandıkçı <git@asandikci.com>
@@ -57,14 +57,23 @@ permissions and JavaScript remain in charge.
57
57
  with a backdrop (mobile).
58
58
  - Dashboard with application cards, model shortcuts and recent activity.
59
59
  - Changelist with search, date hierarchy, collapsible filters, sortable
60
- headers, bulk actions and Bootstrap pagination.
60
+ headers, bulk actions and Bootstrap pagination. The desktop filter panel can
61
+ be collapsed, and both its state and the "Show counts" choice are remembered
62
+ between pages.
63
+ - Subtle vertical rules separate columns in changelists, inline tables and the
64
+ object history.
61
65
  - Change form with card-based fieldsets, collapsible sections, a clear save
62
66
  bar, tabular/stacked inlines and related-object popups.
63
67
  - Modern login, logout and password change pages.
64
68
  - Light, dark and system themes. The existing Django `theme` localStorage
65
69
  preference is synchronised with Bootstrap's `data-bs-theme` attribute.
70
+ - Dark-theme secondary text, table headers, filter links and help text use
71
+ higher-contrast tokens for better readability.
66
72
  - Soft fade-in/fade-out transition when switching themes, skipped
67
73
  automatically for users who prefer reduced motion.
74
+ - Every click changes the visible theme: if `auto` would resolve to the theme
75
+ already on screen, the toggle skips that step instead of appearing to do
76
+ nothing.
68
77
  - Accessible focus states, labels, table headers and ARIA attributes.
69
78
  - All Django admin JavaScript hooks are preserved: `actions.js`,
70
79
  `filters.js`, `nav_sidebar.js`, inlines, Select2/autocomplete, calendar
@@ -236,6 +245,7 @@ Run the tests and the linter:
236
245
 
237
246
  ```bash
238
247
  python -m django test --settings=tests.settings
248
+ node tests/js/theme_toggle.test.js
239
249
  ruff check .
240
250
  ruff format --check .
241
251
  ```
@@ -63,6 +63,7 @@ tests/test_admin_ui.py
63
63
  tests/test_package.py
64
64
  tests/test_templatetags.py
65
65
  tests/urls.py
66
+ tests/js/theme_toggle.test.js
66
67
  tests/testapp/__init__.py
67
68
  tests/testapp/admin.py
68
69
  tests/testapp/apps.py
@@ -0,0 +1,141 @@
1
+ const fs = require("fs");
2
+ const vm = require("vm");
3
+
4
+ const source = fs.readFileSync(
5
+ "src/django_bootstrap6_admin/static/django_bootstrap6_admin/js/theme.js",
6
+ "utf8",
7
+ );
8
+
9
+ class ClassList {
10
+ constructor() {
11
+ this.values = new Set();
12
+ }
13
+ add(value) {
14
+ this.values.add(value);
15
+ }
16
+ remove(value) {
17
+ this.values.delete(value);
18
+ }
19
+ contains(value) {
20
+ return this.values.has(value);
21
+ }
22
+ }
23
+
24
+ function makeElement() {
25
+ return {
26
+ style: {},
27
+ classList: new ClassList(),
28
+ dataset: {},
29
+ attrs: {},
30
+ offsetWidth: 0,
31
+ setAttribute(name, value) {
32
+ this.attrs[name] = value;
33
+ },
34
+ appendChild() {},
35
+ };
36
+ }
37
+
38
+ function sleep(ms) {
39
+ return new Promise((resolve) => setTimeout(resolve, ms));
40
+ }
41
+
42
+ function effective(mode, prefersDark) {
43
+ return mode === "auto" ? (prefersDark ? "dark" : "light") : mode;
44
+ }
45
+
46
+ async function run({ prefersDark, stored, reducedMotion = false }) {
47
+ const root = makeElement();
48
+ const body = makeElement();
49
+ const localStorage = {
50
+ value: stored,
51
+ getItem() {
52
+ return this.value;
53
+ },
54
+ setItem(_key, value) {
55
+ this.value = value;
56
+ },
57
+ };
58
+ const sandbox = {
59
+ console,
60
+ setTimeout,
61
+ clearTimeout,
62
+ localStorage,
63
+ getComputedStyle() {
64
+ return { backgroundColor: "rgb(13, 20, 28)" };
65
+ },
66
+ document: {
67
+ documentElement: root,
68
+ body,
69
+ readyState: "complete",
70
+ createElement: makeElement,
71
+ querySelectorAll() {
72
+ return [];
73
+ },
74
+ addEventListener() {},
75
+ },
76
+ };
77
+ sandbox.window = sandbox;
78
+ sandbox.window.matchMedia = (query) => ({
79
+ matches: query.includes("prefers-reduced-motion")
80
+ ? reducedMotion
81
+ : query.includes("prefers-color-scheme") && prefersDark,
82
+ addEventListener() {},
83
+ });
84
+ sandbox.window.setTimeout = setTimeout;
85
+
86
+ vm.runInNewContext(source, sandbox, { filename: "theme.js" });
87
+
88
+ const api = sandbox.window.Bootstrap6AdminTheme;
89
+ const before = root.dataset.theme;
90
+ const beforeEffective = root.dataset.effectiveTheme;
91
+ api.cycle();
92
+ await sleep(reducedMotion ? 10 : 600);
93
+ const after = root.dataset.theme;
94
+ const afterEffective = root.dataset.effectiveTheme;
95
+
96
+ return { before, beforeEffective, after, afterEffective };
97
+ }
98
+
99
+ (async () => {
100
+ const cases = [
101
+ { label: "dark system, no stored theme", prefersDark: true, stored: null },
102
+ { label: "dark system, stored auto", prefersDark: true, stored: "auto" },
103
+ { label: "dark system, stored dark", prefersDark: true, stored: "dark" },
104
+ { label: "dark system, stored light", prefersDark: true, stored: "light" },
105
+ { label: "light system, no stored theme", prefersDark: false, stored: null },
106
+ { label: "light system, stored auto", prefersDark: false, stored: "auto" },
107
+ { label: "light system, stored light", prefersDark: false, stored: "light" },
108
+ { label: "light system, stored dark", prefersDark: false, stored: "dark" },
109
+ ];
110
+
111
+ let failed = false;
112
+ for (const testCase of cases) {
113
+ const result = await run(testCase);
114
+ const changed = result.beforeEffective !== result.afterEffective;
115
+ if (!changed) {
116
+ failed = true;
117
+ }
118
+ console.log(
119
+ `${changed ? "PASS" : "FAIL"} ${testCase.label}: ` +
120
+ `${result.before}/${result.beforeEffective} -> ` +
121
+ `${result.after}/${result.afterEffective}`,
122
+ );
123
+ }
124
+
125
+ const reduced = await run({
126
+ prefersDark: true,
127
+ stored: "dark",
128
+ reducedMotion: true,
129
+ });
130
+ const reducedChanged = reduced.beforeEffective !== reduced.afterEffective;
131
+ if (!reducedChanged) {
132
+ failed = true;
133
+ }
134
+ console.log(
135
+ `${reducedChanged ? "PASS" : "FAIL"} reduced motion: ` +
136
+ `${reduced.before}/${reduced.beforeEffective} -> ` +
137
+ `${reduced.after}/${reduced.afterEffective}`,
138
+ );
139
+
140
+ process.exit(failed ? 1 : 0);
141
+ })();
@@ -76,6 +76,8 @@ class AdminUiTests(TestCase):
76
76
  'id="changelist-search"',
77
77
  'id="searchbar"',
78
78
  'id="changelist-filter"',
79
+ 'id="ba-filters-toggle"',
80
+ 'id="ba-filters-close"',
79
81
  'id="action-toggle"',
80
82
  'id="date-hierarchy-label"',
81
83
  'class="actions ba-actions"',
@@ -119,7 +121,6 @@ class AdminUiTests(TestCase):
119
121
  def test_changelist_search_and_pagination(self):
120
122
  response = self.client.get(reverse("admin:testapp_book_changelist"), {"q": "Django"})
121
123
  self.assertContains(response, "Django for Everyone")
122
-
123
124
  for index in range(105):
124
125
  Book.objects.create(
125
126
  title=f"Extra book {index:03d}",
@@ -134,6 +135,15 @@ class AdminUiTests(TestCase):
134
135
  self.assertContains(response, "?p=1")
135
136
  self.assertContains(response, 'aria-current="page">2')
136
137
 
138
+ def test_facet_count_toggle_links(self):
139
+ url = reverse("admin:testapp_book_changelist")
140
+
141
+ response = self.client.get(url)
142
+ self.assertContains(response, "Show counts")
143
+
144
+ response = self.client.get(url, {"_facets": "1"})
145
+ self.assertContains(response, "Hide counts")
146
+
137
147
  def test_change_form_keeps_widgets_and_submit_contract(self):
138
148
  response = self.client.get(reverse("admin:testapp_book_change", args=[self.book.pk]))
139
149
 
@@ -46,3 +46,19 @@ class PackageAssetsTests(SimpleTestCase):
46
46
  self.assertIn(".ba-theme-fade", admin_css)
47
47
  self.assertIn(".ba-theme-fade.is-active", admin_css)
48
48
  self.assertIn("transition: opacity 200ms ease", admin_css)
49
+
50
+ def test_changelist_ux_assets(self):
51
+ static_dir = Path(django_bootstrap6_admin.__file__).parent / "static"
52
+ admin_js = (static_dir / "django_bootstrap6_admin" / "js" / "admin.js").read_text(
53
+ encoding="utf-8"
54
+ )
55
+ admin_css = (static_dir / "django_bootstrap6_admin" / "css" / "admin.css").read_text(
56
+ encoding="utf-8"
57
+ )
58
+
59
+ self.assertIn("django.admin.showFacets", admin_js)
60
+ self.assertIn("django.admin.filtersCollapsed", admin_js)
61
+ self.assertIn("ba-filters-collapsed", admin_css)
62
+ self.assertIn("border-inline-end: 1px solid var(--bs-border-muted)", admin_css)
63
+ self.assertIn("appearance: none", admin_css)
64
+ self.assertIn("--bs-fg-4: #9fb1c4", admin_css)