richie 2.34.1.dev9__py2.py3-none-any.whl → 2.34.1.dev16__py2.py3-none-any.whl

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.

Potentially problematic release.


This version of richie might be problematic. Click here for more details.

Files changed (27) hide show
  1. frontend/scss/colors/_theme.scss +8 -0
  2. frontend/scss/components/_header.scss +103 -14
  3. frontend/scss/objects/_selector.scss +1 -0
  4. richie/apps/core/cache.py +175 -2
  5. richie/apps/core/storage.py +63 -0
  6. richie/apps/core/templates/menu/header_menu.html +31 -11
  7. richie/apps/core/templates/richie/base.html +149 -6
  8. richie/apps/core/tests/test_cache.py +159 -0
  9. richie/apps/core/tests/test_settings.py +52 -0
  10. richie/apps/core/utils.py +60 -0
  11. richie/apps/courses/migrations/0037_alter_blogpostpluginmodel_cmsplugin_ptr_and_more.py +230 -0
  12. richie/apps/courses/migrations/0038_alter_mainmenuentry_menu_color.py +25 -0
  13. richie/apps/courses/models/course.py +1 -1
  14. richie/apps/courses/models/menuentry.py +1 -1
  15. richie/plugins/glimpse/migrations/0004_alter_glimpse_cmsplugin_ptr_alter_glimpse_variant.py +49 -0
  16. richie/plugins/html_sitemap/migrations/0002_alter_htmlsitemappage_cmsplugin_ptr.py +28 -0
  17. richie/plugins/large_banner/migrations/0004_alter_largebanner_cmsplugin_ptr.py +28 -0
  18. richie/plugins/lti_consumer/migrations/0004_alter_lticonsumer_cmsplugin_ptr.py +28 -0
  19. richie/plugins/nesteditem/migrations/0004_alter_nesteditem_cmsplugin_ptr.py +28 -0
  20. richie/plugins/plain_text/migrations/0002_alter_plaintext_cmsplugin_ptr.py +28 -0
  21. richie/static/richie/css/main.css +1 -1
  22. {richie-2.34.1.dev9.dist-info → richie-2.34.1.dev16.dist-info}/METADATA +3 -1
  23. {richie-2.34.1.dev9.dist-info → richie-2.34.1.dev16.dist-info}/RECORD +27 -15
  24. {richie-2.34.1.dev9.dist-info → richie-2.34.1.dev16.dist-info}/WHEEL +1 -1
  25. {richie-2.34.1.dev9.dist-info → richie-2.34.1.dev16.dist-info}/LICENSE +0 -0
  26. {richie-2.34.1.dev9.dist-info → richie-2.34.1.dev16.dist-info}/top_level.txt +0 -0
  27. {richie-2.34.1.dev9.dist-info → richie-2.34.1.dev16.dist-info}/zip-safe +0 -0
@@ -47,6 +47,14 @@ $r-theme: (
47
47
  item-cta-hollow-background: transparent,
48
48
  item-cta-hollow-border: transparent,
49
49
  item-divider-border: r-color('light-grey'),
50
+ dropdown-border-radius: 8px,
51
+ dropdown-padding: 0.5rem,
52
+ dropdown-gap: 0.5rem,
53
+ dropdown-background-color: r-color('azure2'),
54
+ dropdown-item-background-color: r-color('indianred3'),
55
+ dropdown-item-text-color: r-color('white'),
56
+ dropdown-item-border-radius: 4px,
57
+ dropdown-item-padding: 0.5rem 1rem,
50
58
  ),
51
59
  body-content: (
52
60
  base-color: r-color('black'),
@@ -168,6 +168,10 @@
168
168
  flex-wrap: nowrap;
169
169
  }
170
170
 
171
+ & > .topbar__list {
172
+ position: relative;
173
+ }
174
+
171
175
  // Aside menu variation
172
176
  &--aside {
173
177
  @include sv-flex(1, 0, auto);
@@ -197,6 +201,76 @@
197
201
  // Menu item element
198
202
  &__item {
199
203
  $item-selector: &;
204
+ &.dropdown {
205
+ display: block;
206
+ position: static;
207
+
208
+ & > button {
209
+ width: 100%;
210
+
211
+ & > .icon {
212
+ height: 1rem;
213
+ margin-left: 0.5rem;
214
+ width: 1rem;
215
+ }
216
+ }
217
+
218
+ & ul[role='menu'] {
219
+ padding-left: 0.5rem;
220
+ }
221
+ }
222
+
223
+ @include media-breakpoint-up($r-topbar-breakpoint) {
224
+ &.dropdown {
225
+ // See header_menu.html for the definition of the variables
226
+ --active-background-color: #{r-theme-val(topbar, dropdown-item-background-color)};
227
+ --active-text-color: #{r-theme-val(topbar, dropdown-item-text-color)};
228
+
229
+ & > button[aria-expanded='true'] {
230
+ background-color: r-theme-val(topbar, dropdown-background-color);
231
+ }
232
+
233
+ & > button[aria-expanded='true'] + .topbar__sublist {
234
+ display: block;
235
+ }
236
+
237
+ & > .topbar__sublist {
238
+ background: r-theme-val(topbar, dropdown-background-color);
239
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
240
+ border-radius: r-theme-val(topbar, dropdown-border-radius);
241
+ display: none;
242
+ position: absolute;
243
+ transform: translateX(-8px);
244
+ max-width: 100%;
245
+
246
+ &--full-width {
247
+ left: 0;
248
+ transform: inherit;
249
+ }
250
+
251
+ & > ul[role='menu'] {
252
+ display: flex;
253
+ flex-wrap: wrap;
254
+ gap: r-theme-val(topbar, dropdown-gap);
255
+ padding: r-theme-val(topbar, dropdown-padding);
256
+ }
257
+
258
+ & .topbar__item > a {
259
+ background-color: #fff;
260
+ border-radius: r-theme-val(topbar, dropdown-item-border-radius);
261
+ padding: r-theme-val(topbar, dropdown-item-padding);
262
+ &::after {
263
+ display: none;
264
+ }
265
+
266
+ &:is(:focus, :hover) {
267
+ background-color: var(--active-background-color);
268
+ color: var(--active-text-color);
269
+ }
270
+ }
271
+ }
272
+ }
273
+ }
200
274
 
201
275
  @include sv-flex(1, 0, auto);
202
276
  display: flex;
@@ -213,15 +287,23 @@
213
287
  --r--menu--item--hover--color: #{r-theme-val(topbar, item-hover-color)};
214
288
  }
215
289
 
216
- & > a {
290
+ & > button {
291
+ @include button-reset-style();
292
+ --radius: #{r-theme-val(topbar, dropdown-border-radius)};
293
+ border-radius: var(--radius) var(--radius) 0 0;
294
+ }
295
+
296
+ & > a,
297
+ & > button {
217
298
  @include sv-flex(1, 0, 100%);
299
+ align-items: center;
300
+ color: inherit;
218
301
  display: flex;
219
- padding: 1rem 0.2rem 1rem 1rem;
220
302
  flex-direction: row;
221
- align-items: center;
222
303
  font-family: inherit;
223
304
  font-weight: inherit;
224
- color: inherit;
305
+ justify-content: space-between;
306
+ padding: 1rem 0.2rem 1rem 1rem;
225
307
 
226
308
  @include media-breakpoint-up($r-topbar-breakpoint) {
227
309
  padding: 1rem 1rem;
@@ -235,18 +317,22 @@
235
317
 
236
318
  // If there is no default hover color we assume there is also no variant
237
319
  @if r-theme-val(topbar, item-hover-color) {
238
- &::after {
239
- content: '';
240
- position: absolute;
241
- bottom: 0;
242
- left: 0;
243
- right: 0;
244
- height: 8px;
320
+ &:is(a)::after {
245
321
  background-color: var(--r--menu--item--hover--color);
246
322
  border-top-left-radius: 0.2rem;
247
323
  border-top-right-radius: 0.2rem;
324
+ bottom: 0;
325
+ content: '';
326
+ height: 8px;
327
+ left: 0;
328
+ position: absolute;
329
+ right: 0;
248
330
  }
249
331
  }
332
+
333
+ &:is(button) {
334
+ background-color: r-theme-val(topbar, dropdown-background-color);
335
+ }
250
336
  }
251
337
  }
252
338
  }
@@ -257,8 +343,10 @@
257
343
 
258
344
  // Current page item or current ancestor
259
345
  &--selected,
260
- &--ancestor {
261
- & > a {
346
+ &--ancestor,
347
+ &:has(.topbar__sublist .topbar__item.topbar__item--selected) {
348
+ & > a,
349
+ & > button {
262
350
  position: relative;
263
351
  color: r-theme-val(topbar, item-active-color);
264
352
 
@@ -328,7 +416,8 @@
328
416
  }
329
417
 
330
418
  // Item divider
331
- & + #{$item-selector} {
419
+ & + #{$item-selector},
420
+ & > .topbar__list #{$item-selector} {
332
421
  @if r-theme-val(topbar, item-divider-border) {
333
422
  border-top: $onepixel solid r-theme-val(topbar, item-divider-border);
334
423
  }
@@ -1,5 +1,6 @@
1
1
  .selector {
2
2
  position: relative;
3
+ z-index: 200;
3
4
 
4
5
  &__button {
5
6
  appearance: none;
richie/apps/core/cache.py CHANGED
@@ -3,22 +3,34 @@ Plugin for django-redis that supports redis sentinel.
3
3
  Credits :
4
4
  - https://github.com/lamoda/django-sentinel
5
5
  - https://github.com/KabbageInc/django-redis-sentinel
6
+
7
+ RedisCache with a fallback cache to prevent denial of service if Redis is down
8
+ Freely inspired by django-cache-fallback
9
+
10
+ Credits:
11
+ - https://github.com/Kub-AT/django-cache-fallback/
6
12
  """
7
13
 
8
14
  import logging
9
15
  import random
10
16
 
11
17
  from django.conf import settings
18
+ from django.core.cache import caches
19
+ from django.core.cache.backends.base import BaseCache
12
20
  from django.core.exceptions import ImproperlyConfigured
13
21
  from django.utils.cache import get_max_age, patch_response_headers
14
22
  from django.utils.deprecation import MiddlewareMixin
15
23
 
24
+ from cms.cache import CMS_PAGE_CACHE_VERSION_KEY
25
+ from django_redis.cache import RedisCache
16
26
  from django_redis.client import DefaultClient
17
27
  from redis.sentinel import Sentinel
18
28
 
19
- DJANGO_REDIS_LOGGER = getattr(settings, "DJANGO_REDIS_LOGGER", __name__)
29
+ from .utils import Throttle
20
30
 
21
- logger = logging.getLogger(__name__)
31
+ FALLBACK_CACHE_INVALIDATION_INTERVAL = 60 # seconds
32
+ DJANGO_REDIS_LOGGER = getattr(settings, "DJANGO_REDIS_LOGGER", __name__)
33
+ logger = logging.getLogger(DJANGO_REDIS_LOGGER)
22
34
 
23
35
 
24
36
  class SentinelClient(DefaultClient):
@@ -174,3 +186,164 @@ class LimitBrowserCacheTTLHeaders(MiddlewareMixin):
174
186
  patch_response_headers(response, cache_timeout=max_ttl)
175
187
 
176
188
  return response
189
+
190
+
191
+ class RedisCacheWithFallback(BaseCache):
192
+ """
193
+ BaseCache object with a redis_cache used as main cache
194
+ and the "fallback" aliased cache which takes over
195
+ in case redis_cache is down.
196
+ """
197
+
198
+ def __init__(self, server, params):
199
+ """
200
+ Instantiate the Redis Cache with server and params
201
+ and retrieve the cache with alias "fallback"
202
+ """
203
+ super().__init__(params)
204
+ self.redis_cache = RedisCache(server, params)
205
+ self.fallback_cache = caches["memory_cache"]
206
+
207
+ def _call_with_fallback(self, method, *args, **kwargs):
208
+ """
209
+ Try first to exec provided method through Redis cache instance,
210
+ in case of success, invalidate the fallback cache so it is clean and
211
+ ready for next failure,
212
+ in case of failure, logger reports the exception and
213
+ the fallback cache takes over.
214
+ """
215
+ try:
216
+ next_cache_state = self._call_redis_cache(method, args, kwargs)
217
+ self._invalidate_fallback_cache()
218
+ return next_cache_state
219
+ except Exception as e: # pylint: disable=W0718
220
+ logger.warning("[DEGRADED CACHE MODE] - Switch to fallback cache")
221
+ logger.exception(e)
222
+ return self._call_fallback_cache(method, args, kwargs)
223
+
224
+ def _call_redis_cache(self, method, args, kwargs):
225
+ """
226
+ Exec the provided method through the redis cache instance
227
+ """
228
+ return getattr(self.redis_cache, method)(*args, **kwargs)
229
+
230
+ def _call_fallback_cache(self, method, args, kwargs):
231
+ """
232
+ Exec the provided method through the fallback cache instance
233
+ """
234
+ return getattr(self.fallback_cache, method)(*args, **kwargs)
235
+
236
+ @Throttle(FALLBACK_CACHE_INVALIDATION_INTERVAL) # 60 seconds
237
+ def _invalidate_fallback_cache(self):
238
+ """
239
+ Invalidate cms page cache in the fallback cache.
240
+ """
241
+ self.fallback_cache.delete(CMS_PAGE_CACHE_VERSION_KEY)
242
+
243
+ def get_backend_timeout(self, *args, **kwargs):
244
+ """
245
+ Pass get_backend_timeout cache method to _call_with_fallback
246
+ """
247
+ return self._call_with_fallback("get_backend_timeout", *args, **kwargs)
248
+
249
+ def make_key(self, *args, **kwargs):
250
+ """
251
+ Pass make_key cache method to _call_with_fallback
252
+ """
253
+ return self._call_with_fallback("make_key", *args, **kwargs)
254
+
255
+ def add(self, *args, **kwargs):
256
+ """
257
+ Pass add cache method to _call_with_fallback
258
+ """
259
+ return self._call_with_fallback("add", *args, **kwargs)
260
+
261
+ def get(self, *args, **kwargs):
262
+ """
263
+ Pass get cache method to _call_with_fallback
264
+ """
265
+ return self._call_with_fallback("get", *args, **kwargs)
266
+
267
+ def set(self, *args, **kwargs):
268
+ """
269
+ Pass set cache method to _call_with_fallback
270
+ """
271
+ return self._call_with_fallback("set", *args, **kwargs)
272
+
273
+ def touch(self, *args, **kwargs):
274
+ """
275
+ Pass touch cache method to _call_with_fallback
276
+ """
277
+ return self._call_with_fallback("touch", *args, **kwargs)
278
+
279
+ def delete(self, *args, **kwargs):
280
+ """
281
+ Pass delete cache method to _call_with_fallback
282
+ """
283
+ return self._call_with_fallback("delete", *args, **kwargs)
284
+
285
+ def get_many(self, *args, **kwargs):
286
+ """
287
+ Pass get_many cache method to _call_with_fallback
288
+ """
289
+ return self._call_with_fallback("get_many", *args, **kwargs)
290
+
291
+ def get_or_set(self, *args, **kwargs):
292
+ """
293
+ Pass get_or_set cache method to _call_with_fallback
294
+ """
295
+ return self._call_with_fallback("get_or_set", *args, **kwargs)
296
+
297
+ def has_key(self, *args, **kwargs):
298
+ """
299
+ Pass has_key cache method to _call_with_fallback
300
+ """
301
+ return self._call_with_fallback("has_key", *args, **kwargs)
302
+
303
+ def incr(self, *args, **kwargs):
304
+ """
305
+ Pass incr cache method to _call_with_fallback
306
+ """
307
+ return self._call_with_fallback("incr", *args, **kwargs)
308
+
309
+ def decr(self, *args, **kwargs):
310
+ """
311
+ Pass decr cache method to _call_with_fallback
312
+ """
313
+ return self._call_with_fallback("decr", *args, **kwargs)
314
+
315
+ def set_many(self, *args, **kwargs):
316
+ """
317
+ Pass set_many cache method to _call_with_fallback
318
+ """
319
+ return self._call_with_fallback("set_many", *args, **kwargs)
320
+
321
+ def delete_many(self, *args, **kwargs):
322
+ """
323
+ Pass delete_many cache method to _call_with_fallback
324
+ """
325
+ return self._call_with_fallback("delete_many", *args, **kwargs)
326
+
327
+ def clear(self):
328
+ """
329
+ Pass clear cache method to _call_with_fallback
330
+ """
331
+ return self._call_with_fallback("clear")
332
+
333
+ def validate_key(self, *args, **kwargs):
334
+ """
335
+ Pass validate_key cache method to _call_with_fallback
336
+ """
337
+ return self._call_with_fallback("validate_key", *args, **kwargs)
338
+
339
+ def incr_version(self, *args, **kwargs):
340
+ """
341
+ Pass incr_version cache method to _call_with_fallback
342
+ """
343
+ return self._call_with_fallback("incr_version", *args, **kwargs)
344
+
345
+ def decr_version(self, *args, **kwargs):
346
+ """
347
+ Pass decr_version cache method to _call_with_fallback
348
+ """
349
+ return self._call_with_fallback("decr_version", *args, **kwargs)
@@ -0,0 +1,63 @@
1
+ """Customizing Django storage backends to enable blue/green deployments."""
2
+
3
+ import re
4
+ from collections import OrderedDict
5
+
6
+ from django.conf import settings
7
+ from django.contrib.staticfiles.storage import ManifestStaticFilesStorage
8
+
9
+ from storages.backends.s3boto3 import S3Boto3Storage
10
+
11
+ STATIC_POSTPROCESS_IGNORE_REGEX = re.compile(
12
+ r"^richie\/js\/build\/[0-9]*\..*\.index\.js(\.map)?$"
13
+ )
14
+
15
+
16
+ class CDNManifestStaticFilesStorage(ManifestStaticFilesStorage):
17
+ """
18
+ Manifest static files storage backend that can be placed behing a CDN
19
+ and ignores files that are already versioned by webpack.
20
+ """
21
+
22
+ def post_process(self, paths, dry_run=False, **options): # pylint: disable=W0221
23
+ """
24
+ Remove paths from file to post process.
25
+ Some js static files generated by webpack already have a unique name per build
26
+ and may be referenced from within the js applications. We therefore don't want
27
+ to hash their name and include them in the manifest file.
28
+ We use a regex configurable via settings to decide which files to ignore.
29
+ Parameters
30
+ ----------
31
+ paths : OrderedDict
32
+ List of files to post process
33
+ dry_run: boolean
34
+ run process but nothing is apply if True
35
+ options: kwargs
36
+ See HashedFilesMixin.post_process
37
+ """
38
+ filtered_paths = OrderedDict()
39
+ for path in paths:
40
+ if not STATIC_POSTPROCESS_IGNORE_REGEX.match(path):
41
+ filtered_paths[path] = paths[path]
42
+
43
+ yield from super().post_process(filtered_paths, dry_run=dry_run, **options)
44
+
45
+ def url(self, name, force=False):
46
+ """
47
+ Prepend static files path by the CDN base url when configured in settings.
48
+ """
49
+ url = super().url(name, force=force)
50
+
51
+ cdn_domain = getattr(settings, "CDN_DOMAIN", None)
52
+ if cdn_domain:
53
+ url = f"//{cdn_domain:s}{url:s}"
54
+
55
+ return url
56
+
57
+
58
+ class MediaStorage(S3Boto3Storage): # pylint: disable=W0223
59
+ """A S3Boto3Storage backend to serve media files via CloudFront."""
60
+
61
+ bucket_name = getattr(settings, "AWS_MEDIA_BUCKET_NAME", None)
62
+ custom_domain = getattr(settings, "CDN_DOMAIN", None)
63
+ file_overwrite = False
@@ -1,22 +1,42 @@
1
- {% load menu_tags %}{% spaceless %}
1
+ {% load cms_tags menu_tags %}{% spaceless %}
2
2
 
3
3
  {% for child in children %}
4
4
  {% with children_slug=child.get_menu_title|slugify menu_options=child.menu_extension %}
5
- <li class="topbar__item dropdown
6
- {% if child.selected %} topbar__item--selected{% endif %}
5
+ <li class="topbar__item
6
+ {% if menu_options.allow_submenu and child.children %}dropdown{% endif %}
7
+ {% if menu_options.allow_submenu and menu_options.menu_color %} topbar__item--{{ menu_options.menu_color }}{% endif %}"
8
+ {% if child.selected or child.attr.redirect_url == current_page.get_absolute_url %} topbar__item--selected{% endif %}
7
9
  {% if child.ancestor %} topbar__item--ancestor{% endif %}
8
10
  {% if child.sibling %} topbar__item--sibling{% endif %}
9
- {% if child.descendant %} topbar__item--descendant{% endif %}
10
- {% if menu_options.menu_color %} topbar__item--{{ menu_options.menu_color }}{% endif %}">
11
- <a href="{{ child.attr.redirect_url|default:child.get_absolute_url }}">
12
- {{ child.get_menu_title }}
13
- </a>
11
+ {% if child.descendant %} topbar__item--descendant{% endif %}"
12
+ >
14
13
  {% comment %}Dropdown menu for children are only for page with index page
15
14
  extension with a specific option enabled{% endcomment %}
16
15
  {% if menu_options.allow_submenu and child.children %}
17
- <ul>
18
- {% show_menu from_level to_level extra_inactive extra_active template "" "" child %}
19
- </ul>
16
+ <button
17
+ aria-expanded="false"
18
+ aria-haspopup="true"
19
+ aria-controls="dropdown-{{ child.id }}"
20
+ id="dropdown-button-{{ child.id }}"
21
+ >
22
+ {{ child.get_menu_title }}
23
+ <svg class="icon" aria-hidden="true">
24
+ <use xlink:href="#icon-chevron-down"></use>
25
+ </svg>
26
+ </button>
27
+ <nav
28
+ class="topbar__sublist"
29
+ id="dropdown-{{ child.id }}"
30
+ aria-labelledby="dropdown-button-{{ child.id }}"
31
+ >
32
+ <ul role="menu">
33
+ {% show_menu from_level to_level extra_inactive extra_active template "" "" child %}
34
+ </ul>
35
+ </nav>
36
+ {% else %}
37
+ <a href="{{ child.attr.redirect_url|default:child.get_absolute_url }}">
38
+ {{ child.get_menu_title }}
39
+ </a>
20
40
  {% endif %}
21
41
  </li>
22
42
  {% endwith %}
@@ -255,16 +255,158 @@
255
255
  }
256
256
  }
257
257
 
258
+ /**
259
+ * Initialize listeners for navigation dropdown menu
260
+ * Add listeners for accessibility features and full-width styling
261
+ * see header_menu.html for the HTML structure
262
+ */
263
+ function initializeDropdownMenu() {
264
+ const $dropdowns = document.querySelectorAll('.topbar__item.dropdown .topbar__sublist');
265
+ const $dropdownButtons = document.querySelectorAll('.topbar__item.dropdown button');
266
+ const $topbar = document.querySelector('.topbar > .topbar__container');
267
+
268
+ /**
269
+ * Apply full-width class to dropdown menu if it's wider than the topbar
270
+ * To compute the width, we need to take into account the position of the menu button
271
+ * and the width of the topbar
272
+ */
273
+ function _updateDropdownWidth($dropdown) {
274
+ const $menu = $dropdown.querySelector('ul[role="menu"]');
275
+ const $menuButton = $dropdown.previousElementSibling;
276
+ const topbarWidth = $topbar.getBoundingClientRect().width;
277
+ const topbarLeft = $topbar.getBoundingClientRect().left;
278
+ const menuButtonLeft = $menuButton.getBoundingClientRect().left;
279
+ $dropdown.style.display = 'block';
280
+ $menu.style.flexWrap = 'nowrap';
281
+ const menuWidth = $menu.getBoundingClientRect().width;
282
+ $dropdown.style = '';
283
+ $menu.style = '';
284
+ const menuRelativeOffsetX = menuButtonLeft - topbarLeft;
285
+
286
+
287
+ if (menuWidth > (topbarWidth - menuRelativeOffsetX)) {
288
+ $dropdown.classList.add('topbar__sublist--full-width');
289
+ } else {
290
+ $dropdown.classList.remove('topbar__sublist--full-width');
291
+ }
292
+ }
293
+
294
+ function _closeDropdown($button) {
295
+ $button.setAttribute('aria-expanded', 'false');
296
+ // Remove focus trap and event listeners when closing
297
+ const $dropdown = document.getElementById($button.getAttribute('aria-controls'));
298
+ const $menuItems = $dropdown.querySelectorAll('a');
299
+ $menuItems.forEach(item => {
300
+ item.removeEventListener('keydown', _handleMenuItemKeydown);
301
+ });
302
+ }
303
+
304
+ function _openDropdown($button) {
305
+ $button.setAttribute('aria-expanded', 'true');
306
+ // Set up focus trap and event listeners when opening
307
+ const $dropdown = document.getElementById($button.getAttribute('aria-controls'));
308
+ const $menuItems = $dropdown.querySelectorAll('a');
309
+ $menuItems.forEach(item => {
310
+ item.setAttribute('role', 'menuitem');
311
+ item.addEventListener('keydown', _handleMenuItemKeydown);
312
+ });
313
+ // Focus first menu item
314
+ if ($menuItems.length) $menuItems[0].focus();
315
+ }
316
+
317
+ /**
318
+ * Allow navigation through dropdown menu items using keyboard
319
+ */
320
+ function _handleMenuItemKeydown(event) {
321
+ const $currentItem = event.target;
322
+ const $dropdown = $currentItem.closest('.topbar__sublist');
323
+ const $menuItems = Array.from($dropdown.querySelectorAll('a'));
324
+ const currentIndex = $menuItems.indexOf($currentItem);
325
+ const $button = document.querySelector(`[aria-controls="${$dropdown.id}"]`);
326
+
327
+ switch (event.key) {
328
+ case 'ArrowDown':
329
+ event.preventDefault();
330
+ const nextIndex = (currentIndex + 1) % $menuItems.length;
331
+ $menuItems[nextIndex].focus();
332
+ break;
333
+ case 'ArrowUp':
334
+ event.preventDefault();
335
+ const prevIndex = (currentIndex - 1 + $menuItems.length) % $menuItems.length;
336
+ $menuItems[prevIndex].focus();
337
+ break;
338
+ case 'Escape':
339
+ event.preventDefault();
340
+ _closeDropdown($button);
341
+ $button.focus();
342
+ break;
343
+ }
344
+ }
345
+
346
+ $dropdowns.forEach(($dropdown, index) => {
347
+ const $button = $dropdownButtons[index];
348
+
349
+ // Initial width calculation
350
+ _updateDropdownWidth($dropdown);
351
+
352
+ // Handle button clicks
353
+ $button.addEventListener('click', () => {
354
+ const isExpanded = $button.getAttribute('aria-expanded') === 'true';
355
+ if (isExpanded) {
356
+ _closeDropdown($button);
357
+ } else {
358
+ // Close any other open dropdowns
359
+ $dropdownButtons.forEach(btn => {
360
+ if (btn !== $button && btn.getAttribute('aria-expanded') === 'true') {
361
+ _closeDropdown(btn);
362
+ }
363
+ });
364
+ _openDropdown($button);
365
+ }
366
+ });
367
+
368
+ // Handle button keyboard interactions
369
+ $button.addEventListener('keydown', (event) => {
370
+ if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
371
+ event.preventDefault();
372
+ _openDropdown($button);
373
+ }
374
+ });
375
+
376
+ // Close when clicking outside
377
+ document.addEventListener('click', (event) => {
378
+ if (!$button.contains(event.target) && !$dropdown.contains(event.target)) {
379
+ _closeDropdown($button);
380
+ }
381
+ });
382
+
383
+ // Close when element within dropdown focus is lost
384
+ $dropdown.addEventListener('focusout', (event) => {
385
+ const $focusedElement = event.relatedTarget;
386
+ if (!$dropdown.contains($focusedElement)) {
387
+ _closeDropdown($button);
388
+ }
389
+ });
390
+ });
391
+
392
+ // Handle window resize
393
+ let resizeTimeout;
394
+ const RESIZE_DEBOUNCE_DELAY = 250;
395
+ window.addEventListener('resize', () => {
396
+ clearTimeout(resizeTimeout);
397
+ resizeTimeout = setTimeout(() => {
398
+ $dropdowns.forEach($dropdown => {
399
+ _updateDropdownWidth($dropdown);
400
+ });
401
+ }, RESIZE_DEBOUNCE_DELAY);
402
+ });
403
+ }
404
+
258
405
  document.addEventListener("DOMContentLoaded", function() {
259
406
  initializeAccordions();
260
407
  initializeHamburgerMenu();
408
+ initializeDropdownMenu();
261
409
  });
262
-
263
- function toggleClass(elements, clazz) {
264
- for (let element of elements) {
265
- element.classList.toggle(clazz);
266
- }
267
- }
268
410
  </script>
269
411
  {% if request.toolbar and request.toolbar.edit_mode_active %}
270
412
  {# When edit mode is active, we have to refresh js scripts after saving modifications #}
@@ -274,6 +416,7 @@
274
416
  __RICHIE__.render();
275
417
  initializeAccordions();
276
418
  initializeHamburgerMenu();
419
+ initializeDropdownMenu();
277
420
  });
278
421
  </script>
279
422
  {% endif %}