Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions T04-Project-2/frontend/src/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
import type { MouseEventHandler, ReactNode } from "react";
import type { MouseEventHandler, ReactNode, Ref } from "react";

type ButtonProps = {
content: ReactNode;
className?: string;
onClick?: MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
ariaLabel?: string;
ariaExpanded?: boolean;
ariaControls?: string;
buttonRef?: Ref<HTMLButtonElement>;
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 (
<button className={className} onClick={onClick} type="button" disabled={disabled} aria-label={ariaLabel} data-open={open}>
<button
ref={buttonRef}
className={className}
onClick={onClick}
type="button"
disabled={disabled}
aria-label={ariaLabel}
aria-expanded={ariaExpanded}
aria-controls={ariaControls}
data-open={open}
>
{content}
</button>
);
Expand Down
22 changes: 17 additions & 5 deletions T04-Project-2/frontend/src/components/FilterDropdown.tsx
Original file line number Diff line number Diff line change
@@ -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;}
Expand All @@ -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<HTMLButtonElement>(null)

function toggleDropdown() {
setOpen((prev) => prev ? false : true)
}


function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape" && open) {
event.preventDefault()
event.stopPropagation()
setOpen(false)
buttonRef.current?.focus()
}
}

return (

<div className="filter-dropdown">
<div className="filter-dropdown" onKeyDown={handleKeyDown}>
<Button
className="filter-dropdown-button"
open={open}
ariaExpanded={open}
ariaControls={panelId}
buttonRef={buttonRef}
content={<ButtonContent
text={filterName}
leftIcon={<CaretDownIcon size={16} weight="bold"/>}
/>}
onClick={toggleDropdown}
/>
<Collapsible open={open} className="filter-dropdown-panel">
<Collapsible id={panelId} open={open} className="filter-dropdown-panel">
<fieldset className="filter-dropdown-content">
<legend className="visually-hidden">{label}</legend>
{options.map((option) => (
Expand Down
27 changes: 22 additions & 5 deletions T04-Project-2/frontend/src/components/FilterMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react"
import { useId, useRef, useState, type KeyboardEvent } from "react"
import "./FilterMenu.css"
import { Button } from "./Button"
import { ButtonContent } from "./ButtonContent"
Expand All @@ -20,6 +20,8 @@ type FilterMenuProps = {
export const FilterMenu = ({ filters, onApply }: FilterMenuProps) => {
const [visible, setVisible] = useState(false)
const [draft, setDraft] = useState(filters)
const menuId = useId()
const buttonRef = useRef<HTMLButtonElement>(null)

function toggleMenu() {
setVisible((prev) => prev ? false : true)
Expand All @@ -29,37 +31,52 @@ export const FilterMenu = ({ filters, onApply }: FilterMenuProps) => {
setDraft((prev) => ({ ...prev, ...changes }))
}

function closeMenu() {
setVisible(false)
buttonRef.current?.focus()
}

function handleApply() {
onApply(draft)
setVisible(false)
closeMenu()
}

function handleReset() {
setDraft(DEFAULT_FILTER_VALUES)
onApply(DEFAULT_FILTER_VALUES)
}

function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape" && visible) {
event.preventDefault()
closeMenu()
}
}

const { data: genres = [] } = useQuery(filterQueryOptions.genres());
const { data: platforms = [] } = useQuery(filterQueryOptions.platforms());
const genreOptions = genres.map((genre) => ({ value: genre.id, label: genre.name }));
const platformOptions = platforms.map((platform) => ({ value: platform.id, label: platform.name }));

return (
<div className="filter-section">
<div className="filter-section" onKeyDown={handleKeyDown}>
<Button
buttonRef={buttonRef}
onClick={toggleMenu}
ariaExpanded={visible}
ariaControls={menuId}
content={<ButtonContent text='Filter' leftIcon={<FunnelIcon size={24}/>}/>}
className='filter-button'
/>
<Collapsible open={visible} className="filter-menu">
<Collapsible id={menuId} open={visible} className="filter-menu">
<form action="" className="filter-menu-content">
<header className="filter-menu-header">
<h2 className="filter-menu-title">Filters</h2>
<button
type="button"
className="filter-close-button"
aria-label="Close filters"
onClick={() => setVisible(false)}
onClick={closeMenu}
>
<XIcon size={24} weight="bold"/>
</button>
Expand Down
27 changes: 20 additions & 7 deletions T04-Project-2/frontend/src/components/GameCardCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number[]>([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]);

Expand All @@ -45,11 +53,16 @@ export const GameCardCarousel = ({gameIds}: GameCardCarouselProps) => {
if (games.length === 0) return null;

return (
<div className="embla game-card-carousel">
<div className="embla game-card-carousel" role="region" aria-roledescription="carousel" aria-label="Game recommendations">
<div className="embla__viewport" ref={emblaRef}>
<ul className="embla__container" role="list">
{games.map((game) => (
<li className="embla__slide" key={game.id}>
{games.map((game, index) => (
<li
className="embla__slide"
key={game.id}
aria-hidden={!visibleSlides.includes(index)}
inert={!visibleSlides.includes(index)}
>
<GameCard
id={game.id}
title={game.name}
Expand Down
12 changes: 10 additions & 2 deletions T04-Project-2/frontend/src/components/LoginPage.css
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
.login-page {
display: grid;
place-items: center;
display: flex;
align-items: flex-start;
justify-content: center;
position: fixed;
inset: 0;
z-index: 2000;
box-sizing: border-box;
padding: 1rem;
overflow-y: auto;
overscroll-behavior: contain;
background: rgba(0, 0, 0, 0.48);
}

.login-card {
width: min(100%, 30rem);
max-height: calc(100vh - 2rem);
max-height: calc(100dvh - 2rem);
box-sizing: border-box;
margin-block: auto;
padding: clamp(1rem, 3vw, 1.5rem);
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 1.25rem;
background: rgba(32, 32, 32, 0.72);
Expand Down
47 changes: 46 additions & 1 deletion T04-Project-2/frontend/src/components/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, type FormEvent } from 'react'
import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from 'react'
import { useMutation } from '@tanstack/react-query'
import { createUser, login, type AuthenticatedUser } from '../api/auth'
import './LoginPage.css'
Expand All @@ -9,6 +9,7 @@ type LoginPageProps = {
}

function LoginPage({ onClose, onAuthenticated }: LoginPageProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const [isCreatingAccount, setIsCreatingAccount] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const loginMutation = useMutation({
Expand All @@ -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<HTMLElement>(
'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<HTMLDivElement>) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}

if (event.key !== 'Tab' || !dialogRef.current) return;

const focusableElements = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(
'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<HTMLFormElement>) => {
event.preventDefault();
setFormError(null);
Expand Down Expand Up @@ -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}
>
<p className="login-eyebrow">GamingGoat account</p>
<h1 id="account-heading">{isCreatingAccount ? 'Create your account' : 'Welcome back'}</h1>
Expand Down
Loading