Add unit tests for LoginButton component with comprehensive coverage
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 46s

This commit is contained in:
2026-02-05 16:37:10 -05:00
parent fd70eca0c9
commit 47541afbbf
47 changed files with 1179 additions and 824 deletions

View File

@@ -8,8 +8,8 @@ import {
logoutParent,
logoutUser,
} from '../../stores/auth'
import { getCachedImageUrl, getCachedImageBlob } from '@/common/imageCache'
import '@/assets/styles.css'
import '@/assets/colors.css'
import ModalDialog from './ModalDialog.vue'
const router = useRouter()
@@ -19,6 +19,63 @@ const error = ref('')
const pinInput = ref<HTMLInputElement | null>(null)
const dropdownOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null)
const avatarButtonRef = ref<HTMLButtonElement | null>(null)
const focusedMenuIndex = ref(0)
// User profile data
const userImageId = ref<string | null>(null)
const userFirstName = ref<string>('')
const userEmail = ref<string>('')
const profileLoading = ref(true)
const avatarImageUrl = ref<string | null>(null)
const dropdownAvatarImageUrl = ref<string | null>(null)
// Compute avatar initial
const avatarInitial = ref<string>('?')
// Fetch user profile
async function fetchUserProfile() {
try {
console.log('Fetching user profile')
const res = await fetch('/api/user/profile', { credentials: 'include' })
if (!res.ok) {
console.error('Failed to fetch user profile')
profileLoading.value = false
return
}
const data = await res.json()
userImageId.value = data.image_id || null
userFirstName.value = data.first_name || ''
userEmail.value = data.email || ''
// Update avatar initial
avatarInitial.value = userFirstName.value ? userFirstName.value.charAt(0).toUpperCase() : '?'
profileLoading.value = false
// Load cached image if available
if (userImageId.value) {
await loadAvatarImages(userImageId.value)
}
} catch (e) {
console.error('Error fetching user profile:', e)
profileLoading.value = false
}
}
async function loadAvatarImages(imageId: string) {
try {
const blob = await getCachedImageBlob(imageId)
// Clean up previous URLs
if (avatarImageUrl.value) URL.revokeObjectURL(avatarImageUrl.value)
if (dropdownAvatarImageUrl.value) URL.revokeObjectURL(dropdownAvatarImageUrl.value)
avatarImageUrl.value = URL.createObjectURL(blob)
dropdownAvatarImageUrl.value = URL.createObjectURL(blob)
} catch (e) {
avatarImageUrl.value = null
dropdownAvatarImageUrl.value = null
}
}
const open = async () => {
// Check if user has a pin
@@ -87,10 +144,71 @@ const handleLogout = () => {
function toggleDropdown() {
dropdownOpen.value = !dropdownOpen.value
if (dropdownOpen.value) {
focusedMenuIndex.value = 0
}
}
function closeDropdown() {
dropdownOpen.value = false
focusedMenuIndex.value = 0
avatarButtonRef.value?.focus()
}
function handleKeyDown(event: KeyboardEvent) {
if (!dropdownOpen.value) {
// Handle avatar button keyboard
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
if (isParentAuthenticated.value) {
toggleDropdown()
} else {
open()
}
}
return
}
// Handle dropdown keyboard navigation
const menuItems = 3 // Profile, Child Mode, Sign Out
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
focusedMenuIndex.value = (focusedMenuIndex.value + 1) % menuItems
break
case 'ArrowUp':
event.preventDefault()
focusedMenuIndex.value = (focusedMenuIndex.value - 1 + menuItems) % menuItems
break
case 'Enter':
case ' ':
event.preventDefault()
executeMenuItem(focusedMenuIndex.value)
break
case 'Escape':
event.preventDefault()
closeDropdown()
break
case 'Tab':
closeDropdown()
break
}
}
function executeMenuItem(index: number) {
switch (index) {
case 0:
goToProfile()
break
case 1:
handleLogout()
closeDropdown()
break
case 2:
signOut()
break
}
}
async function signOut() {
@@ -122,62 +240,115 @@ function handleClickOutside(event: MouseEvent) {
onMounted(() => {
eventBus.on('open-login', open)
document.addEventListener('mousedown', handleClickOutside)
fetchUserProfile()
})
onUnmounted(() => {
eventBus.off('open-login', open)
document.removeEventListener('mousedown', handleClickOutside)
// Revoke object URL to free memory
if (avatarImageUrl.value) URL.revokeObjectURL(avatarImageUrl.value)
if (dropdownAvatarImageUrl.value) URL.revokeObjectURL(dropdownAvatarImageUrl.value)
})
</script>
<template>
<div style="position: relative">
<button
v-if="!isParentAuthenticated"
@click="open"
aria-label="Parent login"
class="login-btn parent-btn"
ref="avatarButtonRef"
@click="isParentAuthenticated ? toggleDropdown() : open()"
@keydown="handleKeyDown"
:aria-label="isParentAuthenticated ? 'Parent menu' : 'Parent login'"
aria-haspopup="menu"
:aria-expanded="isParentAuthenticated && dropdownOpen ? 'true' : 'false'"
class="avatar-btn"
>
Parent
<img
v-if="avatarImageUrl && !profileLoading"
:src="avatarImageUrl"
:alt="userFirstName || 'User avatar'"
class="avatar-image"
/>
<span v-else class="avatar-text">{{ profileLoading ? '...' : avatarInitial }}</span>
</button>
<div v-else style="display: inline-block; position: relative" ref="dropdownRef">
<button @click="toggleDropdown" aria-label="Parent menu" class="login-btn parent-btn">
Parent
</button>
<Transition name="slide-fade">
<div
v-if="dropdownOpen"
v-if="isParentAuthenticated && dropdownOpen"
ref="dropdownRef"
role="menu"
class="dropdown-menu"
style="
position: absolute;
right: 0;
top: 100%;
background: #fff;
border: 1px solid #e6e6e6;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
min-width: 120px;
z-index: 10;
"
>
<button class="menu-item" @click="goToProfile" style="width: 100%; text-align: left">
Profile
<div class="dropdown-header">
<img
v-if="avatarImageUrl"
:src="avatarImageUrl"
:alt="userFirstName || 'User avatar'"
class="dropdown-avatar"
/>
<span v-else class="dropdown-avatar-initial">{{ avatarInitial }}</span>
<div class="dropdown-user-info">
<div class="dropdown-user-name">{{ userFirstName || 'User' }}</div>
<div v-if="userEmail" class="dropdown-user-email">{{ userEmail }}</div>
</div>
</div>
<button
role="menuitem"
:aria-selected="focusedMenuIndex === 0"
:class="['menu-item', { focused: focusedMenuIndex === 0 }]"
@mouseenter="focusedMenuIndex = 0"
@click="goToProfile"
>
<span class="menu-icon-stub">
<img
src="/profile.png"
alt="Profile"
style="width: 20px; height: 20px; object-fit: contain; vertical-align: middle"
/>
</span>
<span>Profile</span>
</button>
<button
class="menu-item"
role="menuitem"
:aria-selected="focusedMenuIndex === 1"
:class="['menu-item', { focused: focusedMenuIndex === 1 }]"
@mouseenter="focusedMenuIndex = 1"
@click="
() => {
handleLogout()
closeDropdown()
}
"
style="width: 100%; text-align: left"
>
Log out
<span class="menu-icon-stub">
<img
src="/child-mode.png"
alt="Child Mode"
style="width: 20px; height: 20px; object-fit: contain; vertical-align: middle"
/>
</span>
<span>Child Mode</span>
</button>
<button class="menu-item danger" @click="signOut" style="width: 100%; text-align: left">
Sign out
<button
role="menuitem"
:aria-selected="focusedMenuIndex === 2"
:class="['menu-item', 'danger', { focused: focusedMenuIndex === 2 }]"
@mouseenter="focusedMenuIndex = 2"
@click="signOut"
>
<span class="menu-icon-stub">
<img
src="/sign-out.png"
alt="Sign out"
style="width: 20px; height: 20px; object-fit: contain; vertical-align: middle"
/>
</span>
<span>Sign out</span>
</button>
</div>
</div>
</Transition>
<ModalDialog v-if="show" title="Enter parent PIN" @click.self="close" @close="close">
<form @submit.prevent="submit">
@@ -201,23 +372,18 @@ onUnmounted(() => {
</template>
<style scoped>
.parent-btn {
width: 65px;
min-width: 65px;
max-width: 65px;
height: 48px;
min-height: 48px;
max-height: 48px;
.avatar-btn {
width: 44px;
min-width: 44px;
max-width: 44px;
height: 44px;
min-height: 44px;
max-height: 44px;
margin: 0;
margin-left: 5px;
margin-right: 5px;
background: var(--button-bg, #fff);
border: 0;
border-radius: 8px 8px 0 0;
border-radius: 50%;
cursor: pointer;
color: var(--button-text, #667eea);
font-weight: 600;
font-size: 0.8rem;
display: flex;
align-items: center;
justify-content: center;
@@ -225,14 +391,37 @@ onUnmounted(() => {
overflow: hidden;
padding: 0;
transition:
background 0.18s,
color 0.18s;
white-space: nowrap;
transform 0.18s,
box-shadow 0.18s;
}
.avatar-btn:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.avatar-btn:focus-visible {
outline: 3px solid var(--primary, #667eea);
outline-offset: 2px;
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.avatar-text {
font-size: 1.5rem;
font-weight: 700;
color: var(--button-text, #667eea);
user-select: none;
}
@media (max-width: 480px) {
.parent-btn {
font-size: 0.7rem;
.avatar-text {
font-size: 1.2rem;
}
}
@@ -248,35 +437,131 @@ onUnmounted(() => {
}
.dropdown-menu {
padding: 0.5rem 0;
position: absolute;
right: 0;
top: calc(100% + 8px);
background: var(--form-bg, #fff);
border: 1px solid var(--form-input-border, #cbd5e1);
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
min-width: 240px;
z-index: 100;
overflow: hidden;
}
.dropdown-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--header-bg, linear-gradient(135deg, #667eea 0%, #764ba2 100%));
color: #fff;
}
.dropdown-avatar,
.dropdown-avatar-initial {
width: 48px;
height: 48px;
min-width: 48px;
min-height: 48px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(255, 255, 255, 0.3);
}
.dropdown-avatar-initial {
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.2);
font-size: 1.5rem;
font-weight: 700;
color: #fff;
}
.dropdown-user-info {
flex: 1;
min-width: 0;
}
.dropdown-user-name {
font-weight: 700;
font-size: 1.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dropdown-user-email {
font-size: 0.85rem;
opacity: 0.9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.menu-item {
padding: 1rem 0.9rem;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
font-weight: 600;
color: var(--menu-item-color, #333);
font-size: 0.9rem;
color: var(--form-label, #444);
font-size: 0.95rem;
transition: background 0.15s;
}
.menu-item + .menu-item {
margin-top: 0.5rem;
.menu-item.focused {
background: var(--btn-secondary-hover, #e2e8f0);
}
.menu-item:hover {
background: var(--menu-item-hover-bg, rgba(0, 0, 0, 0.04));
.menu-item:focus-visible {
outline: 2px solid var(--primary, #667eea);
outline-offset: -2px;
}
.menu-item.danger {
color: var(--menu-item-danger, #ff4d4f);
color: var(--btn-danger, #ef4444);
}
.menu-icon-stub {
width: 20px;
height: 20px;
background: var(--form-input-bg, #f8fafc);
border-radius: 4px;
flex-shrink: 0;
}
/* Slide fade animation */
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.2s ease;
}
.slide-fade-enter-from {
opacity: 0;
transform: translateY(-8px);
}
.slide-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
@media (max-width: 600px) {
.menu-item {
padding: 0.85rem 0.7rem;
padding: 12px 14px;
font-size: 1rem;
}
.menu-item + .menu-item {
margin-top: 0.35rem;
.dropdown-menu {
min-width: 200px;
}
}
</style>