Add secret management guide, client-side performance monitoring, and search accessibility enhancements

- Introduced a comprehensive Secret Management Guide detailing best practices, secret classification, development setup, production management, rotation procedures, and emergency protocols.
- Implemented a client-side performance monitoring script to track various metrics including page load performance, paint metrics, layout shifts, and memory usage.
- Enhanced search accessibility with keyboard navigation support for search results, ensuring compliance with WCAG standards and improving user experience.
This commit is contained in:
pacnpal
2025-12-23 16:41:42 -05:00
parent ae31e889d7
commit edcd8f2076
155 changed files with 22046 additions and 4645 deletions

View File

@@ -2,6 +2,15 @@
{% comment %}
Enhanced Header Component - Matches React Frontend Design
Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
ACCESSIBILITY PATTERNS:
- All dropdown menus use aria-expanded and aria-haspopup for screen readers
- Menu items use role="menu" and role="menuitem" for proper ARIA semantics
- Search inputs have associated labels (sr-only) for screen reader accessibility
- Theme toggle uses aria-pressed state to announce current mode
- Mobile menu uses role="dialog" with aria-modal for modal semantics
- Focus management: Tab, Enter, Escape keys supported on all interactive elements
- Keyboard navigation: Arrow keys for menu items (handled by Alpine.js)
{% endcomment %}
{% load static %}
@@ -27,17 +36,21 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
@mouseleave="open = false"
class="relative"
>
<button
<button
class="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent transition-colors"
@click="open = !open"
@keydown.escape="open = false"
aria-label="Browse menu"
aria-haspopup="true"
:aria-expanded="open.toString()"
>
<i class="fas fa-compass w-4 h-4"></i>
<i class="fas fa-compass w-4 h-4" aria-hidden="true"></i>
Browse
<i class="fas fa-chevron-down w-4 h-4"></i>
<i class="fas fa-chevron-down w-4 h-4" aria-hidden="true"></i>
</button>
<!-- Browse Dropdown -->
<div
<div
x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
@@ -46,83 +59,91 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
x-cloak
role="menu"
aria-label="Browse navigation"
class="absolute left-0 mt-2 w-[480px] p-6 bg-background border rounded-lg shadow-lg z-50"
>
<div class="grid grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<a
href="{% url 'parks:park_list' %}"
<a
href="{% url 'parks:park_list' %}"
role="menuitem"
class="flex items-start gap-3 p-3 rounded-lg hover:bg-accent transition-colors group"
@click="open = false"
>
<i class="fas fa-map-marker-alt w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground"></i>
<i class="fas fa-map-marker-alt w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground" aria-hidden="true"></i>
<div>
<h3 class="font-semibold text-sm mb-1">Parks</h3>
<p class="text-xs text-muted-foreground">Explore theme parks worldwide</p>
<span class="font-semibold text-sm mb-1 block">Parks</span>
<span class="text-xs text-muted-foreground">Explore theme parks worldwide</span>
</div>
</a>
<a
href="{% url 'rides:manufacturer_list' %}"
<a
href="{% url 'rides:manufacturer_list' %}"
role="menuitem"
class="flex items-start gap-3 p-3 rounded-lg hover:bg-accent transition-colors group"
@click="open = false"
>
<i class="fas fa-wrench w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground"></i>
<i class="fas fa-wrench w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground" aria-hidden="true"></i>
<div>
<h3 class="font-semibold text-sm mb-1">Manufacturers</h3>
<p class="text-xs text-muted-foreground">Ride and attraction manufacturers</p>
<span class="font-semibold text-sm mb-1 block">Manufacturers</span>
<span class="text-xs text-muted-foreground">Ride and attraction manufacturers</span>
</div>
</a>
<a
href="{% url 'parks:operator_list' %}"
<a
href="{% url 'parks:operator_list' %}"
role="menuitem"
class="flex items-start gap-3 p-3 rounded-lg hover:bg-accent transition-colors group"
@click="open = false"
>
<i class="fas fa-users w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground"></i>
<i class="fas fa-users w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground" aria-hidden="true"></i>
<div>
<h3 class="font-semibold text-sm mb-1">Operators</h3>
<p class="text-xs text-muted-foreground">Theme park operating companies</p>
<span class="font-semibold text-sm mb-1 block">Operators</span>
<span class="text-xs text-muted-foreground">Theme park operating companies</span>
</div>
</a>
</div>
<!-- Right Column -->
<div class="space-y-4">
<a
href="{% url 'rides:global_ride_list' %}"
<a
href="{% url 'rides:global_ride_list' %}"
role="menuitem"
class="flex items-start gap-3 p-3 rounded-lg hover:bg-accent transition-colors group"
@click="open = false"
>
<i class="fas fa-rocket w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground"></i>
<i class="fas fa-rocket w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground" aria-hidden="true"></i>
<div>
<h3 class="font-semibold text-sm mb-1">Rides</h3>
<p class="text-xs text-muted-foreground">Discover rides and attractions</p>
<span class="font-semibold text-sm mb-1 block">Rides</span>
<span class="text-xs text-muted-foreground">Discover rides and attractions</span>
</div>
</a>
<a
href="{% url 'rides:designer_list' %}"
<a
href="{% url 'rides:designer_list' %}"
role="menuitem"
class="flex items-start gap-3 p-3 rounded-lg hover:bg-accent transition-colors group"
@click="open = false"
>
<i class="fas fa-drafting-compass w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground"></i>
<i class="fas fa-drafting-compass w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground" aria-hidden="true"></i>
<div>
<h3 class="font-semibold text-sm mb-1">Designers</h3>
<p class="text-xs text-muted-foreground">Ride designers and architects</p>
<span class="font-semibold text-sm mb-1 block">Designers</span>
<span class="text-xs text-muted-foreground">Ride designers and architects</span>
</div>
</a>
<a
href="#"
<a
href="#"
role="menuitem"
class="flex items-start gap-3 p-3 rounded-lg hover:bg-accent transition-colors group"
@click="open = false"
>
<i class="fas fa-trophy w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground"></i>
<i class="fas fa-trophy w-5 h-5 mt-0.5 text-muted-foreground group-hover:text-foreground" aria-hidden="true"></i>
<div>
<h3 class="font-semibold text-sm mb-1">Top Lists</h3>
<p class="text-xs text-muted-foreground">Community rankings and favorites</p>
<span class="font-semibold text-sm mb-1 block">Top Lists</span>
<span class="text-xs text-muted-foreground">Community rankings and favorites</span>
</div>
</a>
</div>
@@ -135,10 +156,12 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
<!-- Desktop Right Side -->
<div class="hidden md:flex items-center space-x-4">
<!-- Enhanced Search (HTMX-driven) -->
<div class="relative">
<div class="relative" role="search">
<div class="relative">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"></i>
<label for="desktop-search" class="sr-only">Search parks and rides</label>
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" aria-hidden="true"></i>
<input
id="desktop-search"
type="search"
placeholder="Search parks, rides..."
class="w-[300px] pl-10 pr-20 h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
@@ -148,51 +171,63 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
hx-indicator=".htmx-loading-indicator"
name="q"
autocomplete="off"
aria-describedby="search-results-status"
aria-controls="search-results"
/>
{% include 'components/ui/button.html' with variant='default' size='sm' text='Search' class='absolute right-1 top-1/2 transform -translate-y-1/2' %}
</div>
<!-- Search Results Dropdown: always present and controlled by HTMX swaps -->
<div
<div
id="search-results"
role="listbox"
aria-label="Search results"
class="absolute top-full left-0 right-0 mt-1 bg-background border rounded-md shadow-lg z-50 max-h-96 overflow-y-auto"
aria-live="polite"
>
<!-- Search results will be populated by HTMX -->
</div>
<div id="search-results-status" class="sr-only" aria-live="polite" aria-atomic="true"></div>
</div>
<!-- Theme Toggle -->
<div x-data="themeToggle()">
<button
<button
@click="toggleTheme()"
aria-label="Toggle theme"
:aria-pressed="$store.theme ? $store.theme.isDark.toString() : 'false'"
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-10 w-10"
>
<i class="fas fa-sun h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"></i>
<i class="fas fa-moon absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"></i>
<span class="sr-only">Toggle theme</span>
<i class="fas fa-sun h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" aria-hidden="true"></i>
<i class="fas fa-moon absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" aria-hidden="true"></i>
</button>
</div>
<!-- User Menu -->
{% if user.is_authenticated %}
<div class="relative" x-data="{ open: false }" @click.outside="open = false">
<button @click="open = !open" class="relative h-8 w-8 rounded-full">
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @keydown.escape="open = false">
<button
@click="open = !open"
aria-label="User menu for {{ user.get_full_name|default:user.username }}"
aria-haspopup="true"
:aria-expanded="open.toString()"
class="relative h-8 w-8 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{% if user.profile.avatar %}
<img
src="{{ user.profile.avatar.url }}"
alt="{{ user.get_full_name|default:user.username }}"
<img
src="{{ user.profile.avatar.url }}"
alt="{{ user.get_full_name|default:user.username }}'s profile picture"
class="h-8 w-8 rounded-full object-cover"
/>
{% else %}
<div class="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium">
<div class="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium" aria-hidden="true">
{{ user.get_full_name.0|default:user.username.0|upper }}
</div>
{% endif %}
</button>
<!-- User Dropdown -->
<div
<div
x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
@@ -201,34 +236,36 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
x-cloak
role="menu"
aria-label="User account menu"
class="absolute right-0 mt-2 w-56 bg-background border rounded-md shadow-lg z-50"
>
<div class="flex items-center justify-start gap-2 p-2">
<div class="flex items-center justify-start gap-2 p-2" role="presentation">
<div class="flex flex-col space-y-1 leading-none">
<p class="font-medium">{{ user.get_full_name|default:user.username }}</p>
<p class="w-[200px] truncate text-sm text-muted-foreground">{{ user.email }}</p>
</div>
</div>
<div class="border-t"></div>
<a href="{% url 'profile' user.username %}" class="flex items-center px-2 py-2 text-sm hover:bg-accent">
<i class="fas fa-user mr-2 h-4 w-4"></i>
<div class="border-t" role="separator"></div>
<a href="{% url 'profile' user.username %}" role="menuitem" class="flex items-center px-2 py-2 text-sm hover:bg-accent focus:bg-accent focus:outline-none">
<i class="fas fa-user mr-2 h-4 w-4" aria-hidden="true"></i>
Profile
</a>
<a href="{% url 'settings' %}" class="flex items-center px-2 py-2 text-sm hover:bg-accent">
<i class="fas fa-cog mr-2 h-4 w-4"></i>
<a href="{% url 'settings' %}" role="menuitem" class="flex items-center px-2 py-2 text-sm hover:bg-accent focus:bg-accent focus:outline-none">
<i class="fas fa-cog mr-2 h-4 w-4" aria-hidden="true"></i>
Settings
</a>
{% if has_moderation_access %}
<a href="{% url 'moderation:dashboard' %}" class="flex items-center px-2 py-2 text-sm hover:bg-accent">
<i class="fas fa-shield-alt mr-2 h-4 w-4"></i>
<a href="{% url 'moderation:dashboard' %}" role="menuitem" class="flex items-center px-2 py-2 text-sm hover:bg-accent focus:bg-accent focus:outline-none">
<i class="fas fa-shield-alt mr-2 h-4 w-4" aria-hidden="true"></i>
Moderation
</a>
{% endif %}
<div class="border-t"></div>
<div class="border-t" role="separator"></div>
<form method="post" action="{% url 'account_logout' %}">
{% csrf_token %}
<button type="submit" class="flex items-center w-full px-2 py-2 text-sm text-red-600 hover:bg-accent">
<i class="fas fa-sign-out-alt mr-2 h-4 w-4"></i>
<button type="submit" role="menuitem" class="flex items-center w-full px-2 py-2 text-sm text-red-600 hover:bg-accent focus:bg-accent focus:outline-none">
<i class="fas fa-sign-out-alt mr-2 h-4 w-4" aria-hidden="true"></i>
Log out
</button>
</form>
@@ -262,50 +299,60 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
<div class="md:hidden flex items-center space-x-2 flex-shrink-0">
<!-- Theme Toggle (Mobile) -->
<div x-data="themeToggle()">
<button
<button
@click="toggleTheme()"
aria-label="Toggle theme"
:aria-pressed="$store.theme ? $store.theme.isDark.toString() : 'false'"
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-10 w-10"
>
<i class="fas fa-sun h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"></i>
<i class="fas fa-moon absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"></i>
<i class="fas fa-sun h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" aria-hidden="true"></i>
<i class="fas fa-moon absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" aria-hidden="true"></i>
</button>
</div>
<!-- Mobile User Menu -->
{% if user.is_authenticated %}
<div class="relative" x-data="{ open: false }" @click.outside="open = false">
<button @click="open = !open" class="relative h-8 w-8 rounded-full">
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @keydown.escape="open = false">
<button
@click="open = !open"
aria-label="User menu for {{ user.get_full_name|default:user.username }}"
aria-haspopup="true"
:aria-expanded="open.toString()"
class="relative h-8 w-8 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{% if user.profile.avatar %}
<img
src="{{ user.profile.avatar.url }}"
alt="{{ user.get_full_name|default:user.username }}"
<img
src="{{ user.profile.avatar.url }}"
alt="{{ user.get_full_name|default:user.username }}'s profile picture"
class="h-8 w-8 rounded-full object-cover"
/>
{% else %}
<div class="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium">
<div class="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium" aria-hidden="true">
{{ user.get_full_name.0|default:user.username.0|upper }}
</div>
{% endif %}
</button>
<!-- Mobile User Dropdown -->
<div
<div
x-show="open"
x-transition
x-cloak
role="menu"
aria-label="User account menu"
class="absolute right-0 mt-2 w-56 bg-background border rounded-md shadow-lg z-50"
>
<div class="flex items-center justify-start gap-2 p-2">
<div class="flex items-center justify-start gap-2 p-2" role="presentation">
<div class="flex flex-col space-y-1 leading-none">
<p class="font-medium">{{ user.get_full_name|default:user.username }}</p>
<p class="w-[200px] truncate text-sm text-muted-foreground">{{ user.email }}</p>
</div>
</div>
<div class="border-t"></div>
<div class="border-t" role="separator"></div>
<form method="post" action="{% url 'account_logout' %}">
{% csrf_token %}
<button type="submit" class="flex items-center w-full px-2 py-2 text-sm text-red-600 hover:bg-accent">
<i class="fas fa-sign-out-alt mr-2 h-4 w-4"></i>
<button type="submit" role="menuitem" class="flex items-center w-full px-2 py-2 text-sm text-red-600 hover:bg-accent focus:bg-accent focus:outline-none">
<i class="fas fa-sign-out-alt mr-2 h-4 w-4" aria-hidden="true"></i>
Log out
</button>
</form>
@@ -333,16 +380,19 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
{% endif %}
<!-- Mobile Menu Button -->
<div x-data="{ open: false }">
<button
<div x-data="{ open: false }" @keydown.escape="open = false">
<button
@click="open = !open"
aria-label="Open mobile menu"
aria-haspopup="dialog"
:aria-expanded="open.toString()"
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-10 w-10"
>
<i class="fas fa-bars h-5 w-5"></i>
<i class="fas fa-bars h-5 w-5" aria-hidden="true"></i>
</button>
<!-- Mobile Menu Overlay -->
<div
<div
x-show="open"
x-transition:enter="transition-opacity ease-linear duration-300"
x-transition:enter-start="opacity-0"
@@ -353,9 +403,10 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
x-cloak
class="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
@click="open = false"
aria-hidden="true"
>
<!-- Mobile Menu Panel -->
<div
<div
x-show="open"
x-transition:enter="transition ease-in-out duration-300 transform"
x-transition:enter-start="translate-x-full"
@@ -363,6 +414,9 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
x-transition:leave="transition ease-in-out duration-300 transform"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
role="dialog"
aria-modal="true"
aria-label="Mobile navigation"
class="fixed right-0 top-0 h-full w-full sm:w-96 bg-background border-l shadow-lg"
@click.stop
>
@@ -370,58 +424,59 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
<!-- Mobile Menu Header -->
<div class="flex items-center justify-between p-4 border-b">
<div class="flex items-center space-x-2">
<div class="w-6 h-6 bg-purple-600 rounded flex items-center justify-center">
<div class="w-6 h-6 bg-purple-600 rounded flex items-center justify-center" aria-hidden="true">
<span class="text-white text-xs font-bold">TW</span>
</div>
<span class="font-bold text-lg">ThrillWiki</span>
</div>
<button
<button
@click="open = false"
aria-label="Close mobile menu"
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-10 w-10"
>
<i class="fas fa-times h-5 w-5"></i>
<i class="fas fa-times h-5 w-5" aria-hidden="true"></i>
</button>
</div>
<!-- Mobile Menu Content -->
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<nav class="flex-1 overflow-y-auto p-4 space-y-6" aria-label="Mobile navigation">
<p class="text-sm text-muted-foreground">
Navigate through the ultimate theme park database
</p>
<!-- Navigation Section -->
<div>
<h3 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">
<h3 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3" id="mobile-nav-heading">
NAVIGATION
</h3>
<div class="space-y-1">
<a href="{% url 'home' %}" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent transition-colors" @click="open = false">
<i class="fas fa-home w-4 h-4"></i>
<div class="space-y-1" role="list" aria-labelledby="mobile-nav-heading">
<a href="{% url 'home' %}" role="listitem" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent focus:bg-accent focus:outline-none transition-colors" @click="open = false">
<i class="fas fa-home w-4 h-4" aria-hidden="true"></i>
<span>Home</span>
</a>
<a href="{% url 'search:search' %}" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent transition-colors" @click="open = false">
<i class="fas fa-search w-4 h-4"></i>
<a href="{% url 'search:search' %}" role="listitem" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent focus:bg-accent focus:outline-none transition-colors" @click="open = false">
<i class="fas fa-search w-4 h-4" aria-hidden="true"></i>
<span>Search</span>
</a>
<a href="{% url 'parks:park_list' %}" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent transition-colors" @click="open = false">
<i class="fas fa-map-marker-alt w-4 h-4"></i>
<a href="{% url 'parks:park_list' %}" role="listitem" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent focus:bg-accent focus:outline-none transition-colors" @click="open = false">
<i class="fas fa-map-marker-alt w-4 h-4" aria-hidden="true"></i>
<span>Parks</span>
</a>
<a href="{% url 'rides:global_ride_list' %}" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent transition-colors" @click="open = false">
<i class="fas fa-rocket w-4 h-4"></i>
<a href="{% url 'rides:global_ride_list' %}" role="listitem" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent focus:bg-accent focus:outline-none transition-colors" @click="open = false">
<i class="fas fa-rocket w-4 h-4" aria-hidden="true"></i>
<span>Rides</span>
</a>
<a href="{% url 'rides:manufacturer_list' %}" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent transition-colors" @click="open = false">
<i class="fas fa-wrench w-4 h-4"></i>
<a href="{% url 'rides:manufacturer_list' %}" role="listitem" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent focus:bg-accent focus:outline-none transition-colors" @click="open = false">
<i class="fas fa-wrench w-4 h-4" aria-hidden="true"></i>
<span>Manufacturers</span>
</a>
<a href="{% url 'parks:operator_list' %}" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent transition-colors" @click="open = false">
<i class="fas fa-building w-4 h-4"></i>
<a href="{% url 'parks:operator_list' %}" role="listitem" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent focus:bg-accent focus:outline-none transition-colors" @click="open = false">
<i class="fas fa-building w-4 h-4" aria-hidden="true"></i>
<span>Operators</span>
</a>
</div>
</div>
</div>
</nav>
</div>
</div>
</div>
@@ -430,11 +485,13 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
</div>
<!-- Mobile Search Bar -->
<div class="md:hidden border-t bg-background">
<div class="md:hidden border-t bg-background" role="search">
<div class="px-4 py-3">
<div class="relative">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"></i>
<label for="mobile-search" class="sr-only">Search parks and rides</label>
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" aria-hidden="true"></i>
<input
id="mobile-search"
type="search"
placeholder="Search parks, rides..."
class="w-full pl-10 pr-20 h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
@@ -443,10 +500,14 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
hx-target="#mobile-search-results"
hx-include="this"
name="q"
autocomplete="off"
aria-describedby="mobile-search-results-status"
aria-controls="mobile-search-results"
/>
{% include 'components/ui/button.html' with variant='default' size='sm' text='Search' class='absolute right-1 top-1/2 transform -translate-y-1/2' %}
</div>
<div id="mobile-search-results" class="mt-2"></div>
<div id="mobile-search-results" role="listbox" aria-label="Mobile search results" aria-live="polite" class="mt-2"></div>
<div id="mobile-search-results-status" class="sr-only" aria-live="polite" aria-atomic="true"></div>
</div>
</div>
</header>

View File

@@ -0,0 +1,50 @@
{#
Reusable lazy loading image component with progressive enhancement.
Parameters:
- image_url: The URL of the image to load
- alt_text: Alt text for accessibility (required)
- css_classes: Additional CSS classes (optional)
- width: Image width (optional)
- height: Image height (optional)
- loading: "lazy" (default) or "eager" for above-fold images
- placeholder: Custom placeholder URL (optional, defaults to inline SVG)
- srcset: Responsive image srcset (optional)
- sizes: Responsive image sizes (optional)
- full_src: Full-size image URL for lightbox (optional)
Usage:
{% include "components/lazy_image.html" with image_url=park.image_url alt_text=park.name %}
{% include "components/lazy_image.html" with image_url=ride.thumbnail alt_text=ride.name loading="eager" %}
{% include "components/lazy_image.html" with image_url=photo.url alt_text=photo.caption css_classes="rounded-lg shadow" %}
Cloudflare Images responsive example:
{% include "components/lazy_image.html" with
image_url=photo.url|add:"?width=800"
srcset=photo.url|add:"?width=400 400w, "|add:photo.url|add:"?width=800 800w, "|add:photo.url|add:"?width=1200 1200w"
sizes="(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px"
alt_text=photo.caption
%}
#}
{% load static %}
{# Default placeholder: inline SVG with aspect ratio preservation #}
{% with placeholder_default="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 9'%3E%3Crect fill='%23f3f4f6' width='16' height='9'/%3E%3C/svg%3E" %}
<img
src="{{ placeholder|default:placeholder_default }}"
data-src="{{ image_url }}"
{% if srcset %}data-srcset="{{ srcset }}"{% endif %}
{% if sizes %}sizes="{{ sizes }}"{% endif %}
alt="{{ alt_text|default:'' }}"
class="lazy {{ css_classes|default:'' }}"
loading="{{ loading|default:'lazy' }}"
{% if width %}width="{{ width }}"{% endif %}
{% if height %}height="{{ height }}"{% endif %}
{% if full_src %}data-full-src="{{ full_src }}"{% endif %}
decoding="async"
fetchpriority="{% if loading == 'eager' %}high{% else %}low{% endif %}"
>
{% endwith %}

View File

@@ -10,28 +10,23 @@ Purpose:
and proper ARIA attributes for accessibility.
Usage Examples:
Basic modal:
{% include 'components/modals/modal_base.html' with modal_id='my-modal' title='Modal Title' %}
{% block modal_body %}
<p>Modal content here</p>
{% endblock %}
{% endinclude %}
Basic modal (extending):
{% extends 'components/modals/modal_base.html' %}
{% block modal_body %}
<p>Modal content here</p>
{% endblock %}
Modal with footer:
<div x-data="{ showModal: false }">
<button @click="showModal = true">Open Modal</button>
{% include 'components/modals/modal_base.html' with modal_id='confirm-modal' title='Confirm Action' show_var='showModal' %}
{% block modal_body %}
<p>Are you sure?</p>
{% endblock %}
{% block modal_footer %}
<button @click="showModal = false" class="btn-secondary">Cancel</button>
<button @click="confirmAction(); showModal = false" class="btn-primary">Confirm</button>
{% endblock %}
{% endinclude %}
</div>
{% extends 'components/modals/modal_base.html' %}
{% block modal_body %}
<p>Are you sure?</p>
{% endblock %}
{% block modal_footer %}
<button @click="showModal = false" class="btn-secondary">Cancel</button>
<button @click="confirmAction(); showModal = false" class="btn-primary">Confirm</button>
{% endblock %}
Different sizes:
Include-based usage (pass context via with):
{% include 'components/modals/modal_base.html' with modal_id='lg-modal' title='Large Modal' size='lg' %}
Parameters:
@@ -66,7 +61,7 @@ Accessibility:
{% endcomment %}
{# Default values #}
{% with size=size|default:'md' show_close_button=show_close_button|default:True show_var=show_var|default:'show' close_on_backdrop=close_on_backdrop|default:True close_on_escape=close_on_escape|default:True prevent_scroll=prevent_scroll|default:True %}
{% with size=size|default:'md' show_close_button=show_close_button|default:True show_var=show_var|default:'show' close_on_backdrop=close_on_backdrop|default:True close_on_escape=close_on_escape|default:True prevent_scroll=prevent_scroll|default:True animation=animation|default:'scale' loading=loading|default:False %}
{# Size classes mapping #}
{% if size == 'sm' %}

View File

@@ -1,10 +1,10 @@
{% extends "components/modals/modal_base.html" %}
{% block modal_content %}
{% block modal_body %}
<form hx-post="{{ action }}" hx-target="#modal-container" hx-swap="outerHTML">
{% for field in form %}
{% include "forms/partials/form_field.html" with field=field %}
{% endfor %}
{% include "forms/partials/form_actions.html" %}
</form>
{% endblock %}
{% endblock modal_body %}

View File

@@ -1,5 +1,16 @@
{# Inner modal template - do not use directly, use modal_base.html instead #}
{# Enhanced with animations, focus trap, and loading states #}
{% comment %}
ACCESSIBILITY FEATURES:
- Focus trap: Tab/Shift+Tab cycles through focusable elements within modal
- Home/End keys: Jump to first/last focusable element
- Escape key: Close modal (configurable via close_on_escape)
- aria-modal="true": Indicates modal dialog semantics
- aria-labelledby: References modal title for screen readers
- aria-describedby: References modal body and subtitle for description
- Automatic focus: First focusable element receives focus on open
- Focus restoration: Focus returns to trigger element on close (handled by parent)
{% endcomment %}
{% with animation=animation|default:'scale' loading=loading|default:False %}
@@ -30,11 +41,19 @@
return true;
}
"
@keydown.home.prevent="
const focusables = $el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');
if (focusables[0]) focusables[0].focus();
"
@keydown.end.prevent="
const focusables = $el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');
if (focusables[focusables.length - 1]) focusables[focusables.length - 1].focus();
"
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
{% if title %}aria-labelledby="{{ modal_id }}-title"{% endif %}
aria-describedby="{{ modal_id }}-body">
{% if subtitle %}aria-describedby="{{ modal_id }}-subtitle {{ modal_id }}-body"{% else %}aria-describedby="{{ modal_id }}-body"{% endif %}>
{# Backdrop #}
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"
@@ -103,7 +122,7 @@
{{ title }}
</h3>
{% if subtitle %}
<p class="text-sm text-muted-foreground">{{ subtitle }}</p>
<p id="{{ modal_id }}-subtitle" class="text-sm text-muted-foreground">{{ subtitle }}</p>
{% endif %}
</div>
</div>
@@ -115,8 +134,8 @@
{% if show_close_button %}
<button type="button"
@click="{{ show_var }} = false"
class="p-2 -mr-2 text-muted-foreground hover:text-foreground rounded-lg hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring transition-colors"
aria-label="Close modal">
class="p-2 -mr-2 text-muted-foreground hover:text-foreground rounded-lg hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 transition-colors"
aria-label="Close {{ title|default:'modal' }}">
<i class="fas fa-times text-lg" aria-hidden="true"></i>
</button>
{% endif %}

View File

@@ -97,13 +97,14 @@ Accessibility:
{# Mobile ellipsis for long breadcrumb trails #}
{% if forloop.counter == 1 and items|length > max_visible %}
<li class="flex items-center sm:hidden" aria-hidden="true">
<span class="mx-2 text-muted-foreground/50">
<li class="flex items-center sm:hidden" aria-label="Additional navigation items hidden on mobile">
<span class="mx-2 text-muted-foreground/50" aria-hidden="true">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</span>
<span class="text-muted-foreground">...</span>
<span class="text-muted-foreground" aria-hidden="true">...</span>
<span class="sr-only">{{ items|length|add:'-2' }} items hidden</span>
</li>
{% endif %}
{% endfor %}

View File

@@ -4,6 +4,16 @@ Button Component - Unified Django Template Version of shadcn/ui Button
A versatile button component that supports multiple variants, sizes, icons, and both
button/link elements. Compatible with HTMX and Alpine.js.
ACCESSIBILITY REQUIREMENT:
Icon-only buttons (size='icon') MUST include aria_label parameter.
This is required for screen reader users to understand the button's purpose.
Correct usage:
{% include 'components/ui/button.html' with size='icon' icon=close_svg aria_label='Close dialog' %}
INCORRECT - will be inaccessible:
{% include 'components/ui/button.html' with size='icon' icon=close_svg %}
Usage Examples:
Basic button:
{% include 'components/ui/button.html' with text='Click me' %}
@@ -23,7 +33,7 @@ Usage Examples:
With SVG icon (preferred):
{% include 'components/ui/button.html' with icon=search_icon_svg text='Search' %}
Icon-only button:
Icon-only button (REQUIRES aria_label):
{% include 'components/ui/button.html' with icon=icon_svg size='icon' aria_label='Close' %}
Parameters:
@@ -40,12 +50,18 @@ Parameters:
- disabled: Boolean to disable the button
- class: Additional CSS classes
- id: Element ID
- aria_label: Accessibility label (required for icon-only buttons)
- aria_label: Accessibility label (REQUIRED for icon-only buttons)
- onclick: JavaScript click handler
- hx_get, hx_post, hx_target, hx_swap, hx_trigger, hx_indicator, hx_include: HTMX attributes
- x_data, x_on_click, x_bind, x_show: Alpine.js attributes
- attrs: Additional HTML attributes as string
Accessibility Features:
- Focus ring: Visible focus indicator (focus-visible:ring-2)
- Disabled state: Proper disabled attribute and opacity change
- ARIA label: Required for icon-only buttons for screen reader support
- Keyboard accessible: All buttons are focusable and activatable via keyboard
Security: Icon SVGs are sanitized using the sanitize_svg filter to prevent XSS attacks.
{% endcomment %}