diff --git a/T04-Project-2/frontend/src/components/Button.tsx b/T04-Project-2/frontend/src/components/Button.tsx index 548b885..a77048f 100644 --- a/T04-Project-2/frontend/src/components/Button.tsx +++ b/T04-Project-2/frontend/src/components/Button.tsx @@ -1,4 +1,4 @@ -import type { MouseEventHandler, ReactNode } from "react"; +import type { MouseEventHandler, ReactNode, Ref } from "react"; type ButtonProps = { content: ReactNode; @@ -6,12 +6,25 @@ type ButtonProps = { onClick?: MouseEventHandler; disabled?: boolean; ariaLabel?: string; + ariaExpanded?: boolean; + ariaControls?: string; + buttonRef?: Ref; open?: boolean; }; -export const Button = ({ content, className, onClick, disabled, ariaLabel, open }: ButtonProps) => { +export const Button = ({ content, className, onClick, disabled, ariaLabel, ariaExpanded, ariaControls, buttonRef, open }: ButtonProps) => { return ( - ); diff --git a/T04-Project-2/frontend/src/components/FilterDropdown.tsx b/T04-Project-2/frontend/src/components/FilterDropdown.tsx index 3eac6d2..79256c1 100644 --- a/T04-Project-2/frontend/src/components/FilterDropdown.tsx +++ b/T04-Project-2/frontend/src/components/FilterDropdown.tsx @@ -1,7 +1,7 @@ import { CaretDownIcon } from "@phosphor-icons/react"; import { ButtonContent } from "./ButtonContent"; import { Button } from "./Button"; -import { useState } from "react"; +import { useId, useRef, useState, type KeyboardEvent } from "react"; import { Collapsible } from "./Collapsible"; import './FilterDropdown.css'; type Option = { value: string; label: string;} @@ -16,25 +16,37 @@ type FilterDropdownProps = { export const FilterDropdown = ({ filterName, options, label, selected, onChange}: FilterDropdownProps) => { const [open, setOpen] = useState(false) + const panelId = useId() + const buttonRef = useRef(null) function toggleDropdown() { setOpen((prev) => prev ? false : true) } - + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape" && open) { + event.preventDefault() + event.stopPropagation() + setOpen(false) + buttonRef.current?.focus() + } + } return ( - -
+
diff --git a/T04-Project-2/frontend/src/components/GameCardCarousel.tsx b/T04-Project-2/frontend/src/components/GameCardCarousel.tsx index 8904814..4602a92 100644 --- a/T04-Project-2/frontend/src/components/GameCardCarousel.tsx +++ b/T04-Project-2/frontend/src/components/GameCardCarousel.tsx @@ -21,22 +21,30 @@ export const GameCardCarousel = ({gameIds}: GameCardCarouselProps) => { const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]); const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi]); + const [visibleSlides, setVisibleSlides] = useState([0]); const [canScrollPrev, setCanScrollPrev] = useState(false); const [canScrollNext, setCanScrollNext] = useState(false); useEffect(() => { if (!emblaApi) return; - const updateButtons = () => { + const updateCarouselState = () => { setCanScrollPrev(emblaApi.canScrollPrev()); setCanScrollNext(emblaApi.canScrollNext()); + setVisibleSlides(emblaApi.slidesInView()); }; - updateButtons(); - emblaApi.on("select", updateButtons).on("reInit", updateButtons); + updateCarouselState(); + emblaApi + .on("select", updateCarouselState) + .on("reInit", updateCarouselState) + .on("slidesInView", updateCarouselState); return () => { - emblaApi.off("select", updateButtons).off("reInit", updateButtons); + emblaApi + .off("select", updateCarouselState) + .off("reInit", updateCarouselState) + .off("slidesInView", updateCarouselState); }; }, [emblaApi]); @@ -45,11 +53,16 @@ export const GameCardCarousel = ({gameIds}: GameCardCarouselProps) => { if (games.length === 0) return null; return ( -
+
    - {games.map((game) => ( -
  • + {games.map((game, index) => ( +
  • (null) const [isCreatingAccount, setIsCreatingAccount] = useState(false); const [formError, setFormError] = useState(null); const loginMutation = useMutation({ @@ -31,6 +32,47 @@ function LoginPage({ onClose, onAuthenticated }: LoginPageProps) { }); const isSubmitting = loginMutation.isPending || createUserMutation.isPending; + useEffect(() => { + const previouslyFocused = document.activeElement; + const firstFocusable = dialogRef.current?.querySelector( + 'input:not([disabled]), button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])', + ); + firstFocusable?.focus(); + + return () => { + if (previouslyFocused instanceof HTMLElement && previouslyFocused.isConnected) { + previouslyFocused.focus(); + } + }; + }, []); + + const handleDialogKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + return; + } + + if (event.key !== 'Tab' || !dialogRef.current) return; + + const focusableElements = Array.from(dialogRef.current.querySelectorAll( + 'input:not([disabled]), button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])', + )); + const firstFocusable = focusableElements[0]; + const lastFocusable = focusableElements[focusableElements.length - 1]; + + if (!firstFocusable || !lastFocusable) { + event.preventDefault(); + dialogRef.current.focus(); + } else if (event.shiftKey && document.activeElement === firstFocusable) { + event.preventDefault(); + lastFocusable.focus(); + } else if (!event.shiftKey && document.activeElement === lastFocusable) { + event.preventDefault(); + firstFocusable.focus(); + } + }; + const handleLoginSubmit = (event: FormEvent) => { event.preventDefault(); setFormError(null); @@ -65,7 +107,10 @@ function LoginPage({ onClose, onAuthenticated }: LoginPageProps) { role="dialog" aria-modal="true" aria-labelledby="account-heading" + tabIndex={-1} + ref={dialogRef} onClick={(event) => event.stopPropagation()} + onKeyDown={handleDialogKeyDown} >

    GamingGoat account

    {isCreatingAccount ? 'Create your account' : 'Welcome back'}

    diff --git a/T04-Project-2/frontend/src/components/NavBar.css b/T04-Project-2/frontend/src/components/NavBar.css index d724c7c..9023ff8 100644 --- a/T04-Project-2/frontend/src/components/NavBar.css +++ b/T04-Project-2/frontend/src/components/NavBar.css @@ -184,7 +184,7 @@ @media (max-width: 700px) { .nav-bar { grid-template-columns: minmax(0, 1fr) auto; - padding: 0.5rem 1.25rem; + padding: 0.4rem 1rem; } .nav-title { @@ -202,53 +202,100 @@ } .nav-logo { - width: 55px; + width: 48px; } } @media (max-width: 520px) { .nav-bar { - grid-template-columns: minmax(0, 1fr); - gap: 0.6rem; - padding: 0.5rem 1rem; + grid-template-columns: minmax(0, auto) minmax(0, 1fr); + column-gap: 0.5rem; + row-gap: 0.2rem; + padding: 0.25rem 0.6rem; } - .nav-search-container { + .nav-title-logo-container { grid-column: 1; - grid-row: 3; + grid-row: 1; + min-width: 0; + } + + .nav-search-container { + grid-column: 1 / -1; + grid-row: 2; + min-width: 0; } .nav-account, .nav-bar > .nav-login-button { - grid-column: 1; - grid-row: 2; + grid-column: 2; + grid-row: 1; justify-self: end; max-width: 100%; } .nav-account { - flex-wrap: wrap; - gap: 0.5rem; + flex-wrap: nowrap; + gap: 0.4rem; + min-width: 0; } .nav-account-name { - max-width: clamp(4rem, 24vw, 7rem); - font-size: 0.8rem; + max-width: clamp(2.5rem, 15vw, 5rem); + font-size: 0.75rem; } .nav-account-favorites { - font-size: 0.8rem; + gap: 0.25rem; + font-size: 0.75rem; } .nav-account-favorites-icon { - width: 18px; - height: 18px; + width: 16px; + height: 16px; + } + + .nav-account .nav-login-button, + .nav-bar > .nav-login-button { + padding: 0.3rem 0.55rem; + font-size: 0.75rem; + } + + .nav-title-undertext { + display: none; + } + + .nav-logo { + width: 36px; + } + + .nav-title { + font-size: clamp(0.95rem, 4vw, 1.2rem); + letter-spacing: 0; + } +} + +@media (max-width: 360px) { + .nav-bar { + column-gap: 0.3rem; + padding-inline: 0.45rem; + } + + .nav-account { + gap: 0.25rem; + } + + .nav-account-name { + max-width: 2.25rem; } .nav-account .nav-login-button, .nav-bar > .nav-login-button { - padding: 0.55rem 0.8rem; - font-size: 0.8rem; + padding-inline: 0.4rem; + } + + .nav-logo { + width: 32px; } } diff --git a/T04-Project-2/frontend/src/components/SearchBar.css b/T04-Project-2/frontend/src/components/SearchBar.css index 1c5857d..44713e8 100644 --- a/T04-Project-2/frontend/src/components/SearchBar.css +++ b/T04-Project-2/frontend/src/components/SearchBar.css @@ -57,3 +57,20 @@ font-size: 1rem; } } + +@media (max-width: 520px) { + .search-form { + padding-inline: 0.45rem; + border-radius: var(--border-radius-s); + } + + .search-input { + min-width: 0; + padding: 0.45rem 0.35rem; + } + + .search-button svg { + width: 18px; + height: 18px; + } +} diff --git a/T04-Project-2/frontend/src/components/SortBySection.tsx b/T04-Project-2/frontend/src/components/SortBySection.tsx index c2ed073..2335df2 100644 --- a/T04-Project-2/frontend/src/components/SortBySection.tsx +++ b/T04-Project-2/frontend/src/components/SortBySection.tsx @@ -1,6 +1,6 @@ import { Collapsible } from "./Collapsible" import './SortBySection.css' -import { useState } from "react" +import { useId, useRef, useState, type KeyboardEvent } from "react" import { Button } from "./Button" import { ButtonContent } from "./ButtonContent" import { CaretDownIcon, SortAscendingIcon, SortDescendingIcon } from "@phosphor-icons/react" @@ -23,11 +23,22 @@ const sortOptions = [ export const SortBySection = ({ filterName, label, sortBy, sortOrder, onSortByChange, onSortOrderChange}: SortByProps) => { const [open, setOpen] = useState(false) + const panelId = useId() + const buttonRef = useRef(null) function toggleDropdown() { setOpen((prev) => prev ? false : true) } + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape" && open) { + event.preventDefault() + event.stopPropagation() + setOpen(false) + buttonRef.current?.focus() + } + } + function toggleSortBy(value: SortField) { onSortByChange(sortBy === value ? null : value) } @@ -39,7 +50,7 @@ export const SortBySection = ({ filterName, label, sortBy, sortOrder, onSortByCh const selectedLabel = sortOptions.find((option) => option.value === sortBy)?.label return ( -
    +

    @@ -50,6 +61,9 @@ export const SortBySection = ({ filterName, label, sortBy, sortOrder, onSortByCh

    - +
    {label} {sortOptions.map((option) => ( diff --git a/T04-Project-2/frontend/src/index.css b/T04-Project-2/frontend/src/index.css index 64b5956..f814370 100644 --- a/T04-Project-2/frontend/src/index.css +++ b/T04-Project-2/frontend/src/index.css @@ -43,6 +43,11 @@ body { margin: 0; } +:focus-visible { + outline: 3px solid var(--primary-color); + outline-offset: 3px; +} + h3 { font-size: clamp(1rem, 1.6vw, 1.3rem); margin: 0; diff --git a/T04-Project-2/frontend/tests/components/KeyboardAccessibility.test.tsx b/T04-Project-2/frontend/tests/components/KeyboardAccessibility.test.tsx new file mode 100644 index 0000000..e6437b9 --- /dev/null +++ b/T04-Project-2/frontend/tests/components/KeyboardAccessibility.test.tsx @@ -0,0 +1,78 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { FilterDropdown } from '../../src/components/FilterDropdown' +import LoginPage from '../../src/components/LoginPage' +import { SortBySection } from '../../src/components/SortBySection' + +describe('keyboard accessibility', () => { + it('traps login dialog focus and closes it with Escape', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + const queryClient = new QueryClient() + + render( + + + , + ) + + const username = screen.getByLabelText('Username') + expect(username).toHaveFocus() + + screen.getByRole('button', { name: 'Close' }).focus() + await user.tab() + expect(username).toHaveFocus() + + await user.keyboard('{Escape}') + expect(onClose).toHaveBeenCalledOnce() + }) + + it('closes a filter dropdown with Escape and returns focus to its trigger', async () => { + const user = userEvent.setup() + + render( + , + ) + + const trigger = screen.getByRole('button', { name: 'Genres' }) + await user.click(trigger) + expect(trigger).toHaveAttribute('aria-expanded', 'true') + + await user.click(screen.getByRole('checkbox', { name: 'Adventure' })) + await user.keyboard('{Escape}') + + expect(trigger).toHaveAttribute('aria-expanded', 'false') + expect(trigger).toHaveFocus() + }) + + it('closes the sort dropdown with Escape and returns focus to its trigger', async () => { + const user = userEvent.setup() + + render( + , + ) + + const trigger = screen.getByRole('button', { name: 'Sorting' }) + await user.click(trigger) + await user.click(screen.getByRole('radio', { name: 'Rating' })) + await user.keyboard('{Escape}') + + expect(trigger).toHaveAttribute('aria-expanded', 'false') + expect(trigger).toHaveFocus() + }) +})