diff --git a/app/components/Menu.tsx b/app/components/Menu.tsx deleted file mode 100644 index dc21b1e06..000000000 --- a/app/components/Menu.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Menu as HeadlessUIMenu, Transition } from "@headlessui/react"; -import clsx from "clsx"; -import * as React from "react"; -import { Image } from "./Image"; - -export interface MenuProps { - button: React.ElementType; - items: { - // type: "button"; TODO: type: "link" - text: string; - id: string | number; - icon?: React.ReactNode; - imagePath?: string; - onClick: () => void; - disabled?: boolean; - selected?: boolean; - }[]; - className?: string; - scrolling?: boolean; - opensLeft?: boolean; -} - -export function Menu({ - button, - items, - className, - scrolling, - opensLeft, -}: MenuProps) { - return ( - - - - - {items.map((item) => { - return ( - - {({ active }) => ( - - )} - - ); - })} - - - - ); -} diff --git a/app/components/elements/Menu.module.css b/app/components/elements/Menu.module.css new file mode 100644 index 000000000..6db8d5066 --- /dev/null +++ b/app/components/elements/Menu.module.css @@ -0,0 +1,81 @@ +.itemsContainer { + position: absolute; + top: 32px; + border-radius: var(--rounded); + background-color: var(--bg-darker); + border: var(--border-style); + z-index: 10; + display: flex; + flex-direction: column; + overflow: hidden; + align-items: flex-start; + width: max-content; +} + +.itemsContainerOpensLeft { + right: 0; +} + +.container { + position: relative; +} + +.scrolling { + max-height: 300px !important; + overflow-y: auto; + scrollbar-color: rgb(83 65 91) transparent; + scrollbar-width: thin; + scrollbar-gutter: stable; +} + +.item { + display: flex; + align-items: center; + font-weight: var(--bold); + font-size: var(--fonts-xs); + color: var(--text); + white-space: nowrap; + gap: var(--s-2); + border-radius: var(--rounded-xs); + padding: var(--s-1-5) var(--s-2-5); + background-color: var(--bg-darker); + width: 100%; + border: 0; + outline: none; + justify-content: flex-start; +} + +.item:first-child { + border-radius: 14.5px 14.5px var(--rounded-xs) var(--rounded-xs); +} + +.item:last-child { + border-radius: var(--rounded-xs) var(--rounded-xs) 14.5px 14.5px; +} + +.item[data-focused] { + background-color: var(--theme-very-transparent); +} + +.itemDisabled { + color: var(--text-lighter); + cursor: not-allowed; +} + +.itemSelected { + background-color: var(--theme-transparent); + font-weight: var(--extra-bold); +} + +.itemActive { + color: var(--theme); +} + +.itemIcon { + width: 18px; +} + +.itemImg { + min-width: 24px; + min-height: 24px; +} diff --git a/app/components/elements/Menu.tsx b/app/components/elements/Menu.tsx new file mode 100644 index 000000000..2b71c8ea7 --- /dev/null +++ b/app/components/elements/Menu.tsx @@ -0,0 +1,79 @@ +import clsx from "clsx"; +import { + Menu, + MenuItem, + type MenuItemProps, + MenuTrigger, + Popover, +} from "react-aria-components"; +import { Image } from "../Image"; +import styles from "./Menu.module.css"; + +interface SendouMenuProps { + trigger: React.ReactNode; + scrolling?: boolean; + opensLeft?: boolean; + children: React.ReactNode; +} + +export function SendouMenu({ + children, + trigger, + opensLeft, + scrolling, +}: SendouMenuProps) { + return ( + + {trigger} + + {children} + + + ); +} + +export interface SendouMenuItemProps extends MenuItemProps { + icon?: React.ReactNode; + imagePath?: string; + isActive?: boolean; +} + +export function SendouMenuItem(props: SendouMenuItemProps) { + const textValue = + props.textValue ?? + (typeof props.children === "string" ? props.children : undefined); + return ( + + clsx(styles.item, { + [styles.itemSelected]: isSelected, + [styles.itemDisabled]: isDisabled, + [styles.itemActive]: props.isActive, + }) + } + > + <> + {props.icon ? ( + {props.icon} + ) : null} + {props.imagePath ? ( + + ) : null} + {props.children} + + + ); +} diff --git a/app/components/layout/AnythingAdder.tsx b/app/components/layout/AnythingAdder.tsx index 63e30e000..777e88f11 100644 --- a/app/components/layout/AnythingAdder.tsx +++ b/app/components/layout/AnythingAdder.tsx @@ -1,5 +1,4 @@ -import { useNavigate } from "@remix-run/react"; -import * as React from "react"; +import { Button } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { useUser } from "~/features/auth/core/user"; import { FF_SCRIMS_ENABLED } from "~/features/scrims/scrims-constants"; @@ -16,100 +15,106 @@ import { plusSuggestionsNewPage, userNewBuildPage, } from "~/utils/urls"; -import { Menu, type MenuProps } from "../Menu"; +import { + SendouMenu, + SendouMenuItem, + type SendouMenuItemProps, +} from "../elements/Menu"; import { PlusIcon } from "../icons/Plus"; -const FilterMenuButton = React.forwardRef< - HTMLButtonElement, - React.ButtonHTMLAttributes ->((props, ref) => { - return ( - - ); -}); - export function AnythingAdder() { const { t } = useTranslation(["common"]); const user = useUser(); - const navigate = useNavigate(); if (!user) { return null; } - const items: MenuProps["items"] = [ + const items: Array = [ { id: "tournament", - text: t("header.adder.tournament"), + children: t("header.adder.tournament"), imagePath: navIconUrl("medal"), - onClick: () => navigate(TOURNAMENT_NEW_PAGE), + href: TOURNAMENT_NEW_PAGE, }, { id: "calendarEvent", - text: t("header.adder.calendarEvent"), + children: t("header.adder.calendarEvent"), imagePath: navIconUrl("calendar"), - onClick: () => navigate(CALENDAR_NEW_PAGE), + href: CALENDAR_NEW_PAGE, }, { id: "builds", - text: t("header.adder.build"), + children: t("header.adder.build"), imagePath: navIconUrl("builds"), - onClick: () => navigate(userNewBuildPage(user)), + href: userNewBuildPage(user), }, { id: "team", - text: t("header.adder.team"), + children: t("header.adder.team"), imagePath: navIconUrl("t"), - onClick: () => navigate(NEW_TEAM_PAGE), + href: NEW_TEAM_PAGE, }, FF_SCRIMS_ENABLED ? { id: "scrimPost", - text: t("header.adder.scrimPost"), + children: t("header.adder.scrimPost"), imagePath: navIconUrl("scrims"), - onClick: () => navigate(newScrimPostPage()), + href: newScrimPostPage(), } : null, FF_SCRIMS_ENABLED ? { id: "association", - text: t("header.adder.association"), + children: t("header.adder.association"), imagePath: navIconUrl("associations"), - onClick: () => navigate(newAssociationsPage()), + href: newAssociationsPage(), } : null, { id: "lfgPost", - text: t("header.adder.lfgPost"), + children: t("header.adder.lfgPost"), imagePath: navIconUrl("lfg"), - onClick: () => navigate(lfgNewPostPage()), + href: lfgNewPostPage(), }, { id: "art", - text: t("header.adder.art"), + children: t("header.adder.art"), imagePath: navIconUrl("art"), - onClick: () => navigate(newArtPage()), + href: newArtPage(), }, { id: "vods", - text: t("header.adder.vod"), + children: t("header.adder.vod"), imagePath: navIconUrl("vods"), - onClick: () => navigate(newVodPage()), + href: newVodPage(), }, { id: "plus", - text: t("header.adder.plusSuggestion"), + children: t("header.adder.plusSuggestion"), imagePath: navIconUrl("plus"), - onClick: () => navigate(plusSuggestionsNewPage()), + href: plusSuggestionsNewPage(), }, ].filter((item) => item !== null); - return ; + return ( + + + + } + > + {items.map((item) => ( + + ))} + + ); } diff --git a/app/features/art/routes/art.tsx b/app/features/art/routes/art.tsx index 352785a5a..cc4f4e1b7 100644 --- a/app/features/art/routes/art.tsx +++ b/app/features/art/routes/art.tsx @@ -55,6 +55,7 @@ export const meta: MetaFunction = (args) => { }); }; +// xxx: 401 page should show same as vod export default function ArtPage() { const { t } = useTranslation(["art", "common"]); const data = useLoaderData(); diff --git a/app/features/builds/routes/builds.$slug.tsx b/app/features/builds/routes/builds.$slug.tsx index b6b01bdaa..7b710e0d7 100644 --- a/app/features/builds/routes/builds.$slug.tsx +++ b/app/features/builds/routes/builds.$slug.tsx @@ -8,9 +8,10 @@ import { nanoid } from "nanoid"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { BuildCard } from "~/components/BuildCard"; -import { Button, LinkButton } from "~/components/Button"; +import { LinkButton } from "~/components/Button"; import { Main } from "~/components/Main"; -import { Menu } from "~/components/Menu"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu"; import { BeakerFilledIcon } from "~/components/icons/BeakerFilled"; import { CalendarIcon } from "~/components/icons/Calendar"; import { ChartBarIcon } from "~/components/icons/ChartBar"; @@ -270,20 +271,6 @@ export default function WeaponsBuildsPage() { return `?${params.toString()}`; }; - const FilterMenuButton = React.forwardRef((props, ref) => ( - - )); - const nthOfSameFilter = (index: number) => { const type = filters[index].type; @@ -293,30 +280,43 @@ export default function WeaponsBuildsPage() { return (
- , - onClick: () => handleFilterAdd("ability"), - }, - { - id: "mode", - text: t("builds:filters.type.mode"), - icon: , - onClick: () => handleFilterAdd("mode"), - }, - { - id: "date", - text: t("builds:filters.type.date"), - icon: , - onClick: () => handleFilterAdd("date"), - disabled: filters.some((filter) => filter.type === "date"), - }, - ]} - button={FilterMenuButton} - /> + } + isDisabled={filters.length >= MAX_BUILD_FILTERS} + data-testid="add-filter-button" + > + {t("builds:addFilter")} + + } + > + } + isDisabled={filters.length >= MAX_BUILD_FILTERS} + onAction={() => handleFilterAdd("ability")} + data-testid="menu-item-ability" + > + {t("builds:filters.type.ability")} + + } + onAction={() => handleFilterAdd("mode")} + data-testid="menu-item-mode" + > + {t("builds:filters.type.mode")} + + } + isDisabled={filters.some((filter) => filter.type === "date")} + onAction={() => handleFilterAdd("date")} + data-testid="menu-item-date" + > + {t("builds:filters.type.date")} + +
{ - const { t } = useTranslation(["lfg"]); - - return ( - - ); -}); - const defaultFilters: Record = { Weapon: { _tag: "Weapon", weaponSplIds: [] }, Type: { _tag: "Type", type: "PLAYER_FOR_TEAM" }, @@ -42,14 +24,27 @@ export function LFGAddFilterButton({ const { t } = useTranslation(["lfg"]); return ( - ({ - id: tag, - text: t(`lfg:filters.${tag as LFGFilter["_tag"]}`), - disabled: filters.some((filter) => filter._tag === tag), - onClick: () => addFilter(defaultFilter), - }))} - button={FilterMenuButton} - /> + } + data-testid="add-filter-button" + > + {t("lfg:addFilter")} + + } + > + {Object.entries(defaultFilters).map(([tag, defaultFilter]) => ( + filter._tag === tag)} + onAction={() => addFilter(defaultFilter)} + > + {t(`lfg:filters.${tag as LFGFilter["_tag"]}`)} + + ))} + ); } diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 18b84203b..4dff772a0 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -10,8 +10,8 @@ import { Alert } from "~/components/Alert"; import { Button } from "~/components/Button"; import { Divider } from "~/components/Divider"; import { FormWithConfirm } from "~/components/FormWithConfirm"; -import { Menu } from "~/components/Menu"; import { SendouButton } from "~/components/elements/Button"; +import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu"; import { SendouPopover } from "~/components/elements/Popover"; import { CheckmarkIcon } from "~/components/icons/Checkmark"; import { EyeIcon } from "~/components/icons/Eye"; @@ -490,31 +490,34 @@ function BracketNav({ const bracketNameForButton = (name: string) => name.replace("bracket", ""); - const button = React.forwardRef((props, ref) => ( - - )); - return ( <> {/** MOBILE */} - { - return { - id: bracket.name, - onClick: () => setBracketIdx(i), - text: bracketNameForButton(bracket.name), - }; - })} - button={button} - className="tournament-bracket__menu" - /> + + {bracketNameForButton( + tournament.bracketByIdxOrDefault(bracketIdx).name, + )} + + + } + > + {visibleBrackets.map((bracket, i) => ( + setBracketIdx(i)} + isActive={i === bracketIdx} + > + {bracketNameForButton(bracket.name)} + + ))} + {/** DESKTOP */}
{visibleBrackets.map((bracket, i) => { diff --git a/app/features/user-page/loaders/u.$identifier.builds.server.ts b/app/features/user-page/loaders/u.$identifier.builds.server.ts index 74da0db17..48b8f8fa3 100644 --- a/app/features/user-page/loaders/u.$identifier.builds.server.ts +++ b/app/features/user-page/loaders/u.$identifier.builds.server.ts @@ -4,10 +4,13 @@ import * as BuildRepository from "~/features/builds/BuildRepository.server"; import { sortAbilities } from "~/features/builds/core/ability-sorting.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import type { MainWeaponId } from "~/modules/in-game-lists"; +import type { SerializeFrom } from "~/utils/remix"; import { notFoundIfFalsy, privatelyCachedJson } from "~/utils/remix.server"; import { sortBuilds } from "../core/build-sorting.server"; import { userParamsSchema } from "../user-page-schemas.server"; +export type UserBuildsPageData = SerializeFrom; + export const loader = async ({ params, request }: LoaderFunctionArgs) => { const loggedInUser = await getUserId(request); const { identifier } = userParamsSchema.parse(params); diff --git a/app/features/user-page/routes/u.$identifier.builds.tsx b/app/features/user-page/routes/u.$identifier.builds.tsx index a2f49bce8..c49e71ba3 100644 --- a/app/features/user-page/routes/u.$identifier.builds.tsx +++ b/app/features/user-page/routes/u.$identifier.builds.tsx @@ -6,8 +6,8 @@ import { Button } from "~/components/Button"; import { Dialog } from "~/components/Dialog"; import { FormMessage } from "~/components/FormMessage"; import { Image, WeaponImage } from "~/components/Image"; -import { Menu, type MenuProps } from "~/components/Menu"; import { SubmitButton } from "~/components/SubmitButton"; +import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu"; import { LockIcon } from "~/components/icons/Lock"; import { SortIcon } from "~/components/icons/Sort"; import { TrashIcon } from "~/components/icons/Trash"; @@ -25,7 +25,10 @@ import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; import { DEFAULT_BUILD_SORT } from "../user-page-constants"; import { action } from "../actions/u.$identifier.builds.server"; -import { loader } from "../loaders/u.$identifier.builds.server"; +import { + type UserBuildsPageData, + loader, +} from "../loaders/u.$identifier.builds.server"; export { loader, action }; export const handle: SendouRouteHandle = { @@ -139,42 +142,6 @@ function BuildsFilters({ const showPublicPrivateFilters = user?.id === layoutData.user.id && privateBuildsCount > 0; - const WeaponFilterMenuButton = React.forwardRef((props, ref) => ( - - )); - - const weaponFilterMenuItems = mainWeaponIds - .map((weaponId) => { - const count = data.weaponCounts[weaponId]; - - if (!count) return null; - - const item: MenuProps["items"][number] = { - id: weaponId, - text: `${t(`weapons:MAIN_${weaponId}`)} (${count})`, - icon: , - onClick: () => setWeaponFilter(weaponId), - selected: weaponFilter === weaponId, - }; - - return item; - }) - .filter((item) => item !== null); - return (
) : null} -
); @@ -359,3 +327,57 @@ function ChangeSortingDialogSelect({ ); } + +function WeaponFilterMenu({ + mainWeaponIds, + counts, + weaponFilter, + setWeaponFilter, +}: { + mainWeaponIds: MainWeaponId[]; + counts: UserBuildsPageData["weaponCounts"]; + weaponFilter: BuildFilter; + setWeaponFilter: (weaponFilter: MainWeaponId) => void; +}) { + const { t } = useTranslation(["weapons", "builds"]); + + return ( + + + {t("builds:filters.filterByWeapon")} + + } + > + {mainWeaponIds.map((weaponId) => { + const count = counts[weaponId]; + + if (!count) return null; + + return ( + + } + onAction={() => setWeaponFilter(weaponId)} + isActive={weaponFilter === weaponId} + > + {`${t(`weapons:MAIN_${weaponId}`)} (${count})`} + + ); + })} + + ); +} diff --git a/app/root.tsx b/app/root.tsx index 2e985ff83..1c5178a36 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -11,6 +11,7 @@ import { Scripts, ScrollRestoration, type ShouldRevalidateFunction, + useHref, useLoaderData, useMatches, useNavigate, @@ -21,8 +22,10 @@ import generalI18next from "i18next"; import NProgress from "nprogress"; import * as React from "react"; import { I18nProvider } from "react-aria-components"; +import { RouterProvider } from "react-aria-components"; import { ErrorBoundary as ClientErrorBoundary } from "react-error-boundary"; import { useTranslation } from "react-i18next"; +import type { NavigateOptions } from "react-router-dom"; import { useChangeLanguage } from "remix-i18next/react"; import * as NotificationRepository from "~/features/notifications/NotificationRepository.server"; import { NOTIFICATIONS } from "~/features/notifications/notifications-contants"; @@ -140,6 +143,7 @@ function Document({ }) { const { htmlThemeClass } = useTheme(); const { i18n } = useTranslation(); + const navigate = useNavigate(); const locale = data?.locale ?? DEFAULT_LANGUAGE; // TODO: re-enable after testing if it causes bug where JS is not loading on revisit @@ -175,13 +179,15 @@ function Document({ {process.env.NODE_ENV === "development" && } - - - - - {children} - - + + + + + + {children} + + + { @@ -271,8 +277,6 @@ function useCustomizedCSSVars() { for (const match of matches) { if ((match.data as any)?.[CUSTOMIZED_CSS_VARS_NAME]) { - // cheating TypeScript here but no real way to keep up - // even an illusion of type safety here return Object.fromEntries( Object.entries( (match.data as any)[CUSTOMIZED_CSS_VARS_NAME] as Record< @@ -287,6 +291,12 @@ function useCustomizedCSSVars() { return; } +declare module "react-aria-components" { + interface RouterConfig { + routerOptions: NavigateOptions; + } +} + export default function App() { // prop drilling data instead of using useLoaderData in the child components directly because // useLoaderData can't be used in CatchBoundary and layout is rendered in it as well diff --git a/app/styles/layout.css b/app/styles/layout.css index 1b9869273..60b823923 100644 --- a/app/styles/layout.css +++ b/app/styles/layout.css @@ -30,6 +30,12 @@ overflow: initial; } +.logo:focus-visible { + outline: 2px solid var(--theme); + outline-offset: 2px; + border-radius: var(--rounded); +} + .layout__breadcrumb-separator { font-size: 20px; opacity: 0.4; @@ -96,6 +102,7 @@ .layout__header__button { width: var(--item-size); height: var(--item-size); + outline-offset: 2px; padding: 0.25rem; border: 2px solid; border-color: var(--theme-transparent); @@ -105,6 +112,19 @@ cursor: pointer; } +.layout__header__button:focus-visible { + outline: 2px solid var(--theme); +} + +.layout__user-item { + outline-offset: 2px; + border-radius: 100%; +} + +.layout__user-item:focus-visible { + outline: 2px solid var(--theme); +} + .layout__header__button__icon { width: 1.15rem; }