From 9a237ff26abfcdb907d607e4e42b03aefe301b56 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:51:31 +0300 Subject: [PATCH] CSS modules ownership cleanup Closes #3358 --- app/components/FormMessage.tsx | 29 + app/components/GearSelect.module.css | 16 + app/components/GearSelect.tsx | 2 +- app/components/Label.tsx | 17 +- .../RankTable.module.css} | 55 +- app/components/RankTable.tsx | 62 ++ app/components/SideNav.module.css | 171 ++--- app/components/SideNav.tsx | 44 ++ app/components/TimePopover.tsx | 58 +- app/components/elements/BottomTexts.tsx | 3 +- app/components/elements/Button.tsx | 17 +- app/components/elements/FieldError.tsx | 16 - app/components/elements/FieldMessage.tsx | 14 - .../elements/OrganizationSearch.tsx | 33 +- app/components/elements/Popover.tsx | 28 + app/components/elements/SearchSelect.tsx | 106 ++-- app/components/elements/Select.module.css | 118 ---- app/components/elements/Select.tsx | 70 +-- .../elements/SelectShell.module.css | 117 ++++ app/components/elements/SelectShell.tsx | 126 ++++ app/components/elements/Tabs.tsx | 6 +- app/components/elements/TeamSearch.tsx | 37 +- app/components/elements/TournamentSearch.tsx | 33 +- app/components/elements/UserSearch.tsx | 39 +- app/components/layout/ChatSidebar.tsx | 82 +-- app/components/layout/GlobalSearch.module.css | 119 +--- app/components/layout/GlobalSearch.tsx | 56 +- app/components/layout/NotificationPopover.tsx | 31 + .../layout/SearchResults.module.css | 48 ++ app/components/layout/SearchResults.tsx | 57 ++ app/components/layout/WeaponSearch.module.css | 29 + app/components/layout/WeaponSearch.tsx | 145 ++--- app/components/layout/index.module.css | 69 ++ app/components/layout/index.tsx | 55 +- app/components/match-page/MatchBanner.tsx | 16 + .../match-page/MatchBannerTimer.tsx | 8 +- .../match-page/MatchBannerTopRow.module.css | 10 - .../match-page/MatchBannerTopRow.tsx | 4 +- app/features/badges/badges.module.css | 108 ---- .../badges/routes/badges.$id.edit.module.css | 35 ++ .../badges/routes/badges.$id.edit.tsx | 2 +- .../badges/routes/badges.$id.module.css | 38 ++ app/features/badges/routes/badges.$id.tsx | 3 +- app/features/badges/routes/badges.module.css | 33 + app/features/badges/routes/badges.tsx | 14 +- .../components/PerInkTankGrid.module.css | 38 ++ .../components/PerInkTankGrid.tsx | 2 +- .../build-analyzer/routes/analyzer.module.css | 39 -- .../img-export/components/BuildGraphic.tsx | 17 +- .../img-export/components/Graphic.module.css | 11 - .../img-export/components/Graphic.tsx | 46 +- .../SeasonSummaryGraphic.module.css | 11 + .../components/SeasonSummaryGraphic.tsx | 104 ++-- .../components/TournamentResultsGraphic.tsx | 17 +- .../components/TournamentRunGraphic.tsx | 9 +- .../routes/leaderboards.module.css | 33 + .../leaderboards/routes/leaderboards.tsx | 117 ++-- .../plus.suggestions.module.css} | 60 -- .../routes/plus.suggestions.tsx | 2 +- app/features/plus-suggestions/routes/plus.tsx | 4 +- .../plus-voting/routes/plus.voting.module.css | 59 ++ .../plus-voting/routes/plus.voting.tsx | 3 +- .../scanner/components/AbilityGrid.tsx | 28 +- app/features/scanner/components/DeathCard.tsx | 31 +- .../scanner/components/EventCard.module.css | 103 --- app/features/scanner/components/EventCard.tsx | 13 +- .../components/EventCardShell.module.css | 101 +++ .../scanner/components/EventCardShell.tsx | 101 +++ .../scanner/components/LivePage.module.css | 20 + app/features/scanner/components/LivePage.tsx | 70 +-- .../scanner/components/MapStartCard.tsx | 10 +- app/features/scanner/components/MatchCard.tsx | 12 +- .../scanner/components/MinimapCard.tsx | 38 +- .../scanner/components/ObjectiveCard.tsx | 10 +- .../scanner/components/PlayerStatusCard.tsx | 10 +- .../scanner/components/ScannerApp.module.css | 52 ++ .../scanner/components/ScannerApp.tsx | 3 +- .../ScannerChrome.module.css} | 93 +-- .../scanner/components/ScannerChrome.tsx | 94 +++ .../scanner/components/ScoreboardCard.tsx | 85 ++- .../scanner/components/ScoreboardOwnCard.tsx | 33 +- .../scanner/components/ScreenshotPage.tsx | 33 +- .../scanner/components/StripWeaponsCard.tsx | 10 +- .../scanner/components/VodPage.module.css | 15 + app/features/scanner/components/VodPage.tsx | 88 ++- .../components/SendouQMatchBanner.tsx | 7 +- .../team/routes/t.$customUrl.edit.module.css | 8 + .../team/routes/t.$customUrl.edit.tsx | 2 +- .../team/routes/t.$customUrl.index.module.css | 117 ++++ .../team/routes/t.$customUrl.index.tsx | 2 +- .../team/routes/t.$customUrl.join.module.css | 7 + .../team/routes/t.$customUrl.join.tsx | 2 +- .../team/routes/t.$customUrl.module.css | 155 +++++ app/features/team/routes/t.$customUrl.tsx | 2 +- app/features/team/team.module.css | 293 --------- .../components/Placements.module.css | 11 + .../top-search/components/Placements.tsx | 37 +- .../Bracket/Bracket.browser.test.tsx | 23 +- .../Bracket/BracketColumns.module.css | 28 + .../components/Bracket/BracketColumns.tsx | 57 ++ .../components/Bracket/Elimination.tsx | 44 +- .../components/Bracket/Match.module.css | 214 +++++++ .../components/Bracket/Match.tsx | 29 +- .../Bracket/PlacementsTable.module.css | 97 +++ .../components/Bracket/PlacementsTable.tsx | 2 +- .../components/Bracket/RoundHeader.module.css | 18 + .../components/Bracket/RoundHeader.tsx | 2 +- .../components/Bracket/RoundRobin.tsx | 21 +- .../components/Bracket/Swiss.module.css | 34 + .../components/Bracket/Swiss.tsx | 2 +- .../components/Bracket/bracket.module.css | 389 ------------ .../components/Bracket/index.module.css | 60 ++ .../components/Bracket/index.tsx | 2 +- .../TournamentTeamActions.module.css | 25 + .../components/TournamentTeamActions.tsx | 2 +- .../routes/to.$id.brackets.module.css | 14 + .../routes/to.$id.brackets.tsx | 3 +- .../tournament-bracket.module.css | 589 ------------------ .../components/TournamentMatchBanner.tsx | 7 +- .../components/EventCalendar.module.css | 101 +++ .../components/EventCalendar.tsx | 2 +- .../components/SocialLinksList.module.css | 38 ++ .../components/SocialLinksList.tsx | 2 +- .../routes/org.$slug.module.css | 83 +++ .../routes/org.$slug.tsx | 2 +- .../tournament-organization.module.css | 222 ------- .../components/MarkdownSection.module.css | 27 + .../tournament/components/MarkdownSection.tsx | 11 + .../components/TeamWithRoster.module.css | 117 ++++ .../tournament/components/TeamWithRoster.tsx | 2 +- .../components/TournamentStream.module.css | 33 + .../components/TournamentStream.tsx | 2 +- .../tournament/routes/to.$id.info.module.css | 28 - .../tournament/routes/to.$id.info.tsx | 6 +- .../tournament/routes/to.$id.join.module.css | 10 + .../tournament/routes/to.$id.join.tsx | 2 +- .../routes/to.$id.register.module.css | 60 ++ .../tournament/routes/to.$id.register.tsx | 10 +- .../routes/to.$id.results.module.css | 54 ++ .../tournament/routes/to.$id.results.tsx | 2 +- .../tournament/routes/to.$id.rules.tsx | 9 +- .../routes/to.$id.streams.module.css | 6 + .../tournament/routes/to.$id.streams.tsx | 2 +- .../routes/to.$id.teams.$tid.module.css | 159 +++++ .../tournament/routes/to.$id.teams.$tid.tsx | 2 +- app/features/tournament/tournament.module.css | 540 ---------------- .../trophies/routes/trophies.$id.module.css | 47 ++ app/features/trophies/routes/trophies.$id.tsx | 2 +- .../trophies/routes/trophies.module.css | 48 -- .../components/UserResultsTable.module.css | 7 + .../user-page/components/UserResultsTable.tsx | 2 +- app/features/user-page/components/Widget.tsx | 2 +- .../components/WidgetSettingsForm.tsx | 4 - .../routes/u.$identifier.builds.module.css | 6 + .../user-page/routes/u.$identifier.builds.tsx | 9 +- .../routes/u.$identifier.index.module.css | 301 +++++++++ .../user-page/routes/u.$identifier.index.tsx | 33 +- .../user-page/routes/u.$identifier.module.css | 137 ---- ....$identifier.results.highlights.module.css | 32 + .../u.$identifier.results.highlights.tsx | 2 +- .../routes/u.$identifier.results.module.css | 6 + .../routes/u.$identifier.results.tsx | 2 +- .../u.$identifier.seasons.index.module.css | 79 +++ .../routes/u.$identifier.seasons.index.tsx | 2 +- .../routes/u.$identifier.seasons.module.css | 23 + .../u.$identifier.seasons.stats.module.css | 89 +++ .../routes/u.$identifier.seasons.stats.tsx | 2 +- .../routes/u.$identifier.seasons.tsx | 2 +- .../user-page/routes/u.$identifier.tsx | 1 - .../user-page/routes/u.$identifier.vods.tsx | 10 +- app/features/user-page/user-page.module.css | 411 ------------ .../vods/components/VodListing.module.css | 7 + app/features/vods/components/VodListing.tsx | 4 + app/features/vods/routes/vods.module.css | 6 - app/features/vods/routes/vods.tsx | 7 +- app/form/SendouForm.browser.test.tsx | 13 +- app/form/fields/FormFieldWrapper.module.css | 9 + app/form/fields/FormFieldWrapper.tsx | 16 + app/form/fields/TeamSearchFormField.tsx | 37 +- app/form/fields/TournamentSearchFormField.tsx | 39 +- .../fields/UserSearchFormField.module.css | 7 - app/form/fields/UserSearchFormField.tsx | 37 +- 182 files changed, 4537 insertions(+), 4545 deletions(-) create mode 100644 app/components/GearSelect.module.css rename app/{features/top-search/top-search.module.css => components/RankTable.module.css} (50%) create mode 100644 app/components/RankTable.tsx delete mode 100644 app/components/elements/FieldError.tsx delete mode 100644 app/components/elements/FieldMessage.tsx create mode 100644 app/components/elements/SelectShell.module.css create mode 100644 app/components/elements/SelectShell.tsx create mode 100644 app/components/layout/SearchResults.module.css create mode 100644 app/components/layout/SearchResults.tsx create mode 100644 app/components/layout/WeaponSearch.module.css delete mode 100644 app/features/badges/badges.module.css create mode 100644 app/features/badges/routes/badges.$id.edit.module.css create mode 100644 app/features/badges/routes/badges.$id.module.css create mode 100644 app/features/badges/routes/badges.module.css create mode 100644 app/features/build-analyzer/components/PerInkTankGrid.module.css rename app/features/plus-suggestions/{plus.module.css => routes/plus.suggestions.module.css} (53%) create mode 100644 app/features/plus-voting/routes/plus.voting.module.css create mode 100644 app/features/scanner/components/EventCardShell.module.css create mode 100644 app/features/scanner/components/EventCardShell.tsx rename app/features/scanner/{scanner.module.css => components/ScannerChrome.module.css} (62%) create mode 100644 app/features/scanner/components/ScannerChrome.tsx create mode 100644 app/features/team/routes/t.$customUrl.edit.module.css create mode 100644 app/features/team/routes/t.$customUrl.index.module.css create mode 100644 app/features/team/routes/t.$customUrl.join.module.css create mode 100644 app/features/team/routes/t.$customUrl.module.css delete mode 100644 app/features/team/team.module.css create mode 100644 app/features/top-search/components/Placements.module.css create mode 100644 app/features/tournament-bracket/components/Bracket/BracketColumns.module.css create mode 100644 app/features/tournament-bracket/components/Bracket/BracketColumns.tsx create mode 100644 app/features/tournament-bracket/components/Bracket/Match.module.css create mode 100644 app/features/tournament-bracket/components/Bracket/PlacementsTable.module.css create mode 100644 app/features/tournament-bracket/components/Bracket/RoundHeader.module.css create mode 100644 app/features/tournament-bracket/components/Bracket/Swiss.module.css delete mode 100644 app/features/tournament-bracket/components/Bracket/bracket.module.css create mode 100644 app/features/tournament-bracket/components/Bracket/index.module.css create mode 100644 app/features/tournament-bracket/components/TournamentTeamActions.module.css create mode 100644 app/features/tournament-bracket/routes/to.$id.brackets.module.css delete mode 100644 app/features/tournament-bracket/tournament-bracket.module.css create mode 100644 app/features/tournament-organization/components/EventCalendar.module.css create mode 100644 app/features/tournament-organization/components/SocialLinksList.module.css create mode 100644 app/features/tournament-organization/routes/org.$slug.module.css delete mode 100644 app/features/tournament-organization/tournament-organization.module.css create mode 100644 app/features/tournament/components/MarkdownSection.module.css create mode 100644 app/features/tournament/components/MarkdownSection.tsx create mode 100644 app/features/tournament/components/TeamWithRoster.module.css create mode 100644 app/features/tournament/components/TournamentStream.module.css create mode 100644 app/features/tournament/routes/to.$id.join.module.css create mode 100644 app/features/tournament/routes/to.$id.register.module.css create mode 100644 app/features/tournament/routes/to.$id.results.module.css create mode 100644 app/features/tournament/routes/to.$id.streams.module.css create mode 100644 app/features/tournament/routes/to.$id.teams.$tid.module.css delete mode 100644 app/features/tournament/tournament.module.css create mode 100644 app/features/trophies/routes/trophies.$id.module.css create mode 100644 app/features/user-page/components/UserResultsTable.module.css create mode 100644 app/features/user-page/routes/u.$identifier.index.module.css delete mode 100644 app/features/user-page/routes/u.$identifier.module.css create mode 100644 app/features/user-page/routes/u.$identifier.results.highlights.module.css create mode 100644 app/features/user-page/routes/u.$identifier.results.module.css create mode 100644 app/features/user-page/routes/u.$identifier.seasons.index.module.css create mode 100644 app/features/user-page/routes/u.$identifier.seasons.module.css create mode 100644 app/features/user-page/routes/u.$identifier.seasons.stats.module.css delete mode 100644 app/features/user-page/user-page.module.css delete mode 100644 app/features/vods/routes/vods.module.css delete mode 100644 app/form/fields/UserSearchFormField.module.css diff --git a/app/components/FormMessage.tsx b/app/components/FormMessage.tsx index 54562586a..8471b78ee 100644 --- a/app/components/FormMessage.tsx +++ b/app/components/FormMessage.tsx @@ -1,5 +1,6 @@ import clsx from "clsx"; import type * as React from "react"; +import { FieldError, Text } from "react-aria-components"; import styles from "./FormMessage.module.css"; export function FormMessage({ @@ -28,3 +29,31 @@ export function FormMessage({ ); } + +/** Field-level error, wired to the surrounding React Aria field's validation. */ +export function SendouFieldError({ + children, + id, +}: { + children?: React.ReactNode; + id?: string; +}) { + return ( + + {children} + + ); +} + +/** Field-level hint below an input. */ +export function SendouFieldMessage({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/app/components/GearSelect.module.css b/app/components/GearSelect.module.css new file mode 100644 index 000000000..f76f663ff --- /dev/null +++ b/app/components/GearSelect.module.css @@ -0,0 +1,16 @@ +.item { + display: flex; + gap: var(--s-2); + align-items: center; +} + +.weaponImg { + min-width: 24px; +} + +.weaponLabel { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0; +} diff --git a/app/components/GearSelect.tsx b/app/components/GearSelect.tsx index 472f19d46..83afae5df 100644 --- a/app/components/GearSelect.tsx +++ b/app/components/GearSelect.tsx @@ -14,7 +14,7 @@ import { import type { GearType } from "~/modules/in-game-lists/types"; import { brandImageUrl, gearImageUrl } from "~/utils/urls"; -import styles from "./WeaponSelect.module.css"; +import styles from "./GearSelect.module.css"; interface GearSelectProps { label?: string; diff --git a/app/components/Label.tsx b/app/components/Label.tsx index 51db154d2..207835af0 100644 --- a/app/components/Label.tsx +++ b/app/components/Label.tsx @@ -33,7 +33,14 @@ export function Label({ {children} {required && *} {valueLimits ? ( -
+
{valueLimits.current}/{valueLimits.max}
) : null} @@ -41,9 +48,9 @@ export function Label({ ); } -function lengthWarning(valueLimits: NonNullable) { - if (valueLimits.current > valueLimits.max) return styles.valueError; - if (valueLimits.current / valueLimits.max >= 0.9) return styles.valueWarning; +function lengthState(valueLimits: NonNullable) { + if (valueLimits.current > valueLimits.max) return "error"; + if (valueLimits.current / valueLimits.max >= 0.9) return "warning"; - return; + return undefined; } diff --git a/app/features/top-search/top-search.module.css b/app/components/RankTable.module.css similarity index 50% rename from app/features/top-search/top-search.module.css rename to app/components/RankTable.module.css index 107c3a6e8..31b52fc9c 100644 --- a/app/features/top-search/top-search.module.css +++ b/app/components/RankTable.module.css @@ -6,27 +6,12 @@ font-weight: var(--weight-semi); } -.tableRank { +.rank { min-width: 28px; text-align: right; } -.tableName { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - max-width: 117px; -} - -.tierHeader { - display: flex; - align-items: center; - gap: var(--s-2); - margin-block: var(--s-2); - color: var(--color-text-high); -} - -.tableRow { +.row { background-color: var(--color-bg-high); display: flex; padding: var(--s-2) var(--s-3); @@ -49,48 +34,18 @@ } } -a.tableRow:hover { +a.row:hover { background-color: var(--color-bg-higher); } -.tableRowQualification { - font-size: var(--font-xs); - font-weight: var(--weight-semi); - justify-content: center; - background-color: var(--color-bg-higher); - display: flex; - gap: var(--s-2); -} - -.tableWeapon { +.weapon { background-color: var(--color-bg); border-radius: 100%; } -.tableMode { - background-color: var(--color-bg); - border-radius: 100%; - padding: var(--s-1); -} - -.time { - font-size: var(--font-xs); - font-weight: var(--weight-semi); - color: var(--color-text-high); -} - -.tablePower { - margin-inline-start: auto; -} - -.tableInnerRow { +.innerRow { display: flex; align-items: center; gap: var(--s-3); width: 100%; } - -.avatar { - min-width: 24px; - min-height: 24px; -} diff --git a/app/components/RankTable.tsx b/app/components/RankTable.tsx new file mode 100644 index 000000000..5643668d2 --- /dev/null +++ b/app/components/RankTable.tsx @@ -0,0 +1,62 @@ +/** + * Rows of ranked entries (rank, avatar, weapon, name, power) as rendered by the + * leaderboards and the X Rank top search pages. + */ + +import clsx from "clsx"; +import type * as React from "react"; +import { Link } from "react-router"; +import { WeaponImage } from "~/components/Image"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import styles from "./RankTable.module.css"; + +export function RankTable({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function RankTableRow({ + to, + className, + testId, + children, +}: { + /** when given the row is a link to the entry's own page */ + to?: string; + className?: string; + testId?: string; + children: React.ReactNode; +}) { + if (!to) { + return
{children}
; + } + + return ( + + {children} + + ); +} + +export function RankTableInnerRow({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function RankTableRank({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function RankTableWeaponImage({ + weaponSplId, +}: { + weaponSplId: MainWeaponId; +}) { + return ( + + ); +} diff --git a/app/components/SideNav.module.css b/app/components/SideNav.module.css index b36a14270..3174563b0 100644 --- a/app/components/SideNav.module.css +++ b/app/components/SideNav.module.css @@ -57,126 +57,6 @@ flex-shrink: 0; } -.sideNavFooterUser { - display: flex; - align-items: center; - gap: var(--s-2); - color: var(--color-text); - text-decoration: none; - font-size: var(--font-xs); - font-weight: var(--weight-semi); - border-radius: var(--radius-field); - padding: var(--s-1); - margin-left: calc(-1 * var(--s-1)); - flex: 1; - min-width: 0; - - &:hover { - background-color: var(--color-bg-high); - } -} - -.sideNavFooterUsername { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sideNavFooterActions { - display: flex; - align-items: center; - gap: var(--s-0-5); - margin-left: auto; -} - -.sideNavFooterButton { - display: grid; - place-items: center; - background-color: transparent; - width: var(--field-size-sm); - height: var(--field-size-sm); - padding: 0; - border: none; - border-radius: var(--radius-field); - color: var(--color-text-high); - cursor: pointer; - - &:hover { - background-color: var(--color-bg-high); - color: var(--color-text); - } - - &:focus-visible { - outline: var(--focus-ring); - outline-offset: -2px; - } - - & svg { - width: 18px; - height: 18px; - } -} - -.sideNavFooterNotification { - position: relative; -} - -.sideNavFooterUnseenDot { - --dot-top: 2px; - --dot-right: 2px; -} - -.sideNavHeader { - color: var(--color-text-high); - padding: var(--s-1-5) var(--s-2); - margin-inline: calc(-1 * var(--s-1-5)); - background-color: var(--color-bg-high); - display: flex; - align-items: center; - gap: var(--s-2); - border-color: var(--color-border); - border-top: 1.5px solid var(--color-border); - border-bottom: 1.5px solid var(--color-border); - height: var(--layout-nav-height); - - &:first-child { - margin-block-start: calc(-1 * var(--s-1-5)); - border-top: none; - } - - & h2 { - font-size: var(--font-xs); - font-weight: var(--weight-bold); - } - - & svg { - width: 18px; - } -} - -.sideNavHeaderAction { - display: flex; - align-items: center; - margin-inline-start: auto; - font-size: var(--font-2xs); - font-weight: var(--weight-normal); - - & a { - color: var(--color-text-high); - text-decoration: none; - - &:hover { - color: var(--color-text); - text-decoration: underline; - } - } -} - -.sideNavHeaderClose { - margin-inline-start: auto; - margin-inline-end: var(--s-1); -} - .iconContainer { background-color: var(--color-bg-high); border-radius: var(--radius-field); @@ -301,6 +181,57 @@ background-color: var(--color-text-second); } +.sideNavHeader { + color: var(--color-text-high); + padding: var(--s-1-5) var(--s-2); + margin-inline: calc(-1 * var(--s-1-5)); + background-color: var(--color-bg-high); + display: flex; + align-items: center; + gap: var(--s-2); + border-color: var(--color-border); + border-top: 1.5px solid var(--color-border); + border-bottom: 1.5px solid var(--color-border); + height: var(--layout-nav-height); + + &:first-child { + margin-block-start: calc(-1 * var(--s-1-5)); + border-top: none; + } + + & h2 { + font-size: var(--font-xs); + font-weight: var(--weight-bold); + } + + & svg { + width: 18px; + } +} + +.sideNavHeaderAction { + display: flex; + align-items: center; + margin-inline-start: auto; + font-size: var(--font-2xs); + font-weight: var(--weight-normal); + + & a { + color: var(--color-text-high); + text-decoration: none; + + &:hover { + color: var(--color-text); + text-decoration: underline; + } + } +} + +.sideNavHeaderClose { + margin-inline-start: auto; + margin-inline-end: var(--s-1); +} + @media screen and (min-width: 1475px) { :global(html[data-fuse="true"]) .sideNav { z-index: 20; diff --git a/app/components/SideNav.tsx b/app/components/SideNav.tsx index 05e3b1d58..1a88df0b1 100644 --- a/app/components/SideNav.tsx +++ b/app/components/SideNav.tsx @@ -218,3 +218,47 @@ export function ListButton({ export function SideNavFooter({ children }: { children: React.ReactNode }) { return
{children}
; } + +export function NavIconContainer({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function NavListButton({ + children, + className, + onPress, +}: { + children: React.ReactNode; + className?: string; + onPress: () => void; +}) { + return ( + + ); +} + +export function NavListImage({ src }: { src: string }) { + return ; +} + +export function NavListTexts({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function NavListTitle({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + {children} + ); +} + +export function NavListSubtitle({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/app/components/TimePopover.tsx b/app/components/TimePopover.tsx index dfb9369a1..7caf3863c 100644 --- a/app/components/TimePopover.tsx +++ b/app/components/TimePopover.tsx @@ -1,11 +1,10 @@ import clsx from "clsx"; import { Check, Clipboard } from "lucide-react"; import { useRef, useState } from "react"; -import { Dialog, Popover } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { SendouButton } from "./elements/Button"; -import popoverStyles from "./elements/Popover.module.css"; +import { SendouAnchoredPopover } from "./elements/Popover"; import { LocaleTime } from "./LocaleTime"; import styles from "./TimePopover.module.css"; @@ -52,40 +51,37 @@ export default function TimePopover({ > - - -
-
- -
- copyToClipboard(``)} - icon={copySuccess ? : } - > - {t("common:actions.copyTimestampForDiscord")} - - {footerText ? ( -
- {footerText} -
- ) : null} +
+
+
-
-
+ copyToClipboard(``)} + icon={copySuccess ? : } + > + {t("common:actions.copyTimestampForDiscord")} + + {footerText ? ( +
+ {footerText} +
+ ) : null} +
+ ); } diff --git a/app/components/elements/BottomTexts.tsx b/app/components/elements/BottomTexts.tsx index 7ade714b1..8e5ef6592 100644 --- a/app/components/elements/BottomTexts.tsx +++ b/app/components/elements/BottomTexts.tsx @@ -1,5 +1,4 @@ -import { SendouFieldError } from "~/components/elements/FieldError"; -import { SendouFieldMessage } from "~/components/elements/FieldMessage"; +import { SendouFieldError, SendouFieldMessage } from "~/components/FormMessage"; // TODO: deprecate in favor of FormMessage export function SendouBottomTexts({ diff --git a/app/components/elements/Button.tsx b/app/components/elements/Button.tsx index 59d87c5b5..2e05ad35e 100644 --- a/app/components/elements/Button.tsx +++ b/app/components/elements/Button.tsx @@ -138,7 +138,8 @@ function buttonClassName({ const variantToClassname = (variant: ButtonVariant) => { switch (variant) { case "primary": - return styles.primary; + // the base look, no extra class needed + return null; case "success": return styles.success; case "destructive": @@ -188,3 +189,17 @@ function iconClassName( [styles.buttonIconBig]: size === "big", }); } + +/** + * Renders the button look on a plain element, for when the interactive element + * is elsewhere, e.g. the box inside a tab. + */ +export function ButtonLook({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}) { + return
{children}
; +} diff --git a/app/components/elements/FieldError.tsx b/app/components/elements/FieldError.tsx deleted file mode 100644 index 9b593798c..000000000 --- a/app/components/elements/FieldError.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { FieldError as ReactAriaFieldError } from "react-aria-components"; -import styles from "../FormMessage.module.css"; - -export function SendouFieldError({ - children, - id, -}: { - children?: React.ReactNode; - id?: string; -}) { - return ( - - {children} - - ); -} diff --git a/app/components/elements/FieldMessage.tsx b/app/components/elements/FieldMessage.tsx deleted file mode 100644 index aff03fa9f..000000000 --- a/app/components/elements/FieldMessage.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Text } from "react-aria-components"; -import styles from "../FormMessage.module.css"; - -export function SendouFieldMessage({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - {children} - - ); -} diff --git a/app/components/elements/OrganizationSearch.tsx b/app/components/elements/OrganizationSearch.tsx index 6e73dab06..8805d7647 100644 --- a/app/components/elements/OrganizationSearch.tsx +++ b/app/components/elements/OrganizationSearch.tsx @@ -1,11 +1,12 @@ -import clsx from "clsx"; import * as React from "react"; -import { ListBoxItem, type SelectProps } from "react-aria-components"; +import type { SelectProps } from "react-aria-components"; import { useFetcher } from "react-router"; import type { SearchLoaderData } from "~/features/search/routes/search"; -import { SearchSelect } from "./SearchSelect"; -import searchSelectStyles from "./SearchSelect.module.css"; -import selectStyles from "./Select.module.css"; +import { + SearchSelect, + SearchSelectItem, + SearchSelectItemAdditionalText, +} from "./SearchSelect"; import { useEntitySearch } from "./useEntitySearch"; export type OrganizationSearchResult = Extract< @@ -100,23 +101,15 @@ function useInitialOrganization(initialOrganizationId?: number) { function OrganizationItem({ item }: { item: OrganizationSearchResult }) { return ( - - clsx(searchSelectStyles.item, { - [selectStyles.itemFocused]: isFocused, - [selectStyles.itemSelected]: isSelected, - }) - } - data-testid="organization-search-item" + testId="organization-search-item" > -
- {item.name} -
- /{item.slug} -
-
-
+ {item.name} + + /{item.slug} + + ); } diff --git a/app/components/elements/Popover.tsx b/app/components/elements/Popover.tsx index 467370e82..6abf5fc86 100644 --- a/app/components/elements/Popover.tsx +++ b/app/components/elements/Popover.tsx @@ -47,3 +47,31 @@ export function SendouPopover({ ); } + +/** + * Popover anchored to a trigger rendered outside of it, with its open state + * controlled by the caller. Prefer `SendouPopover` when the trigger can be + * passed in. + */ +export function SendouAnchoredPopover({ + children, + isOpen, + onOpenChange, + triggerRef, +}: { + children: React.ReactNode; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + triggerRef: React.RefObject; +}) { + return ( + + {children} + + ); +} diff --git a/app/components/elements/SearchSelect.tsx b/app/components/elements/SearchSelect.tsx index bbd1213c1..c52d87dd6 100644 --- a/app/components/elements/SearchSelect.tsx +++ b/app/components/elements/SearchSelect.tsx @@ -1,24 +1,23 @@ -import clsx from "clsx"; -import { ChevronsUpDown, Search, X } from "lucide-react"; import type * as React from "react"; import { Autocomplete, - Button, - Input, type Key, - ListBox, ListBoxItem, - Popover, - SearchField, - Select, type SelectProps, SelectValue, } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { SendouBottomTexts } from "~/components/elements/BottomTexts"; import { SendouLabel } from "~/components/elements/Label"; -import searchSelectStyles from "./SearchSelect.module.css"; -import selectStyles from "./Select.module.css"; +import styles from "./SearchSelect.module.css"; +import { + SelectShell, + SelectShellItem, + SelectShellListBox, + SelectShellPopover, + SelectShellSearchField, + SelectShellTrigger, +} from "./SelectShell"; import type { EntitySearch } from "./useEntitySearch"; const PLACEHOLDER_TEXTS = { @@ -80,7 +79,7 @@ export function SearchSelect< ...rest }: SearchSelectProps) { return ( - - - - + + {(item) => typeof item.id === "string" ? ( @@ -132,10 +115,10 @@ export function SearchSelect< renderItem(item as TItem) ) } - + - - + + ); } @@ -154,7 +137,7 @@ function PlaceholderItem({ {id === "PLACEHOLDER" ? t(PLACEHOLDER_TEXTS[i18nKey].placeholder) @@ -162,3 +145,46 @@ function PlaceholderItem({ ); } + +/** + * One result inside a `SearchSelect`'s list: an optional leading avatar or + * logo, then the texts. `SearchSelectItemAdditionalText` renders the muted + * second line, which is hidden while the item is shown in the trigger. + */ +export function SearchSelectItem({ + id, + textValue, + testId, + leading, + children, +}: { + id: number; + textValue: string; + testId: string; + leading?: React.ReactNode; + children: React.ReactNode; +}) { + return ( + + {leading} +
{children}
+
+ ); +} + +export function SearchSelectItemLogo({ src }: { src: string }) { + return ; +} + +export function SearchSelectItemAdditionalText({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} diff --git a/app/components/elements/Select.module.css b/app/components/elements/Select.module.css index 5147fb063..45bc325d0 100644 --- a/app/components/elements/Select.module.css +++ b/app/components/elements/Select.module.css @@ -1,32 +1,3 @@ -.button { - height: var(--field-size); - padding: 0 var(--field-padding); - border: var(--border-style); - border-radius: var(--radius-field); - background-color: var(--color-bg); - outline: none; - - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--s-1-5); - width: 100%; - cursor: pointer; - - &[data-focus-visible], - &[aria-expanded="true"] { - outline: var(--focus-ring); - outline-offset: 1px; - } - - &[data-disabled] { - pointer-events: none; - cursor: not-allowed; - opacity: 0.5; - outline: none; - } -} - .selectValue { font-size: var(--font-sm); overflow: hidden; @@ -40,29 +11,6 @@ } } -.icon { - min-width: 18px; - max-width: 18px; - color: var(--color-text-high); -} - -.popover { - padding: var(--s-1); - width: var(--trigger-width); - border: var(--border-style); - border-radius: var(--radius-box); - background-color: var(--color-bg); - outline: none; - - display: flex; - flex-direction: column; -} - -.listBox { - overflow: auto; - flex: 1; -} - .item { font-size: var(--font-sm); font-weight: var(--weight-semi); @@ -77,64 +25,6 @@ } } -.itemFocused { - background-color: var(--color-bg-high); - color: var(--color-text); -} - -.itemSelected { - color: var(--color-text-accent); - font-weight: var(--weight-bold); -} - -.searchField { - position: relative; - display: flex; - gap: var(--s-2); - - border-bottom: 1px solid var(--color-border); - border-radius: 0; - accent-color: var(--color-accent); - color: var(--color-text); - outline: none; - padding: var(--s-1-5) var(--s-1-5) calc(var(--s-0-5) + var(--s-2)) - var(--s-1-5); - margin-block-end: var(--s-1-5); -} - -.searchInput { - all: unset; - font-size: var(--font-xs); - font-weight: var(--weight-semi); - letter-spacing: 0.5px; - flex: 1; - min-width: 0; - padding-inline-end: calc(18px + var(--s-2)); - - &::-webkit-search-cancel-button { - display: none; - } - - &::placeholder { - color: var(--color-text-high); - } -} - -.searchClearButton { - position: absolute; - right: var(--s-2); - top: 50%; - transform: translateY(-50%); - background-color: transparent; - border: none; - padding: 0; - cursor: pointer; -} - -[data-empty] .searchClearButton { - visibility: hidden; -} - .noResults { font-size: var(--font-md); font-weight: var(--weight-bold); @@ -143,14 +33,6 @@ color: var(--color-text-high); } -.select { - width: 100%; - position: relative; - display: flex; - flex-direction: column; - gap: var(--s-1-5); -} - .label { font-size: var(--font-xs); font-weight: var(--weight-bold); diff --git a/app/components/elements/Select.tsx b/app/components/elements/Select.tsx index a87de77fe..ada2cb383 100644 --- a/app/components/elements/Select.tsx +++ b/app/components/elements/Select.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { ChevronsUpDown, Search, X } from "lucide-react"; +import { X } from "lucide-react"; import * as React from "react"; import type { AutocompleteProps, @@ -8,17 +8,10 @@ import type { } from "react-aria-components"; import { Autocomplete, - Button, Header, - Input, Label, - ListBox, - ListBoxItem, ListBoxSection, ListLayout, - Popover, - SearchField, - Select, SelectStateContext, SelectValue, useFilter, @@ -29,6 +22,14 @@ import { SendouBottomTexts } from "~/components/elements/BottomTexts"; import { SendouButton } from "~/components/elements/Button"; import { Image } from "../Image"; import styles from "./Select.module.css"; +import { + SelectShell, + SelectShellItem, + SelectShellListBox, + SelectShellPopover, + SelectShellSearchField, + SelectShellTrigger, +} from "./SelectShell"; const ROW_HEIGHT = 33; @@ -108,15 +109,15 @@ export function SendouSelect({ estimatedRowHeight ? { estimatedRowHeight } : { rowHeight: ROW_HEIGHT } } > - (
{t("common:noResults")}
)} > {children} -
+ ); @@ -126,21 +127,18 @@ export function SendouSelect({ const filterable = !!search || isControlled || !!filter; return ( - - - + ) : null} {listBox} ) : ( listBox )} - - + + ); } interface SendouSelectItemProps extends ListBoxItemProps {} export function SendouSelectItem(props: SendouSelectItemProps) { - return ( - - clsx(styles.item, { - [styles.itemFocused]: isFocused, - [styles.itemSelected]: isSelected, - }) - } - /> - ); + return ; } interface SendouSelectItemSectionProps { diff --git a/app/components/elements/SelectShell.module.css b/app/components/elements/SelectShell.module.css new file mode 100644 index 000000000..5ef0a76a2 --- /dev/null +++ b/app/components/elements/SelectShell.module.css @@ -0,0 +1,117 @@ +.button { + height: var(--field-size); + padding: 0 var(--field-padding); + border: var(--border-style); + border-radius: var(--radius-field); + background-color: var(--color-bg); + outline: none; + + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s-1-5); + width: 100%; + cursor: pointer; + + &[data-focus-visible], + &[aria-expanded="true"] { + outline: var(--focus-ring); + outline-offset: 1px; + } + + &[data-disabled] { + pointer-events: none; + cursor: not-allowed; + opacity: 0.5; + outline: none; + } +} + +.icon { + min-width: 18px; + max-width: 18px; + color: var(--color-text-high); +} + +.popover { + padding: var(--s-1); + width: var(--trigger-width); + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg); + outline: none; + + display: flex; + flex-direction: column; +} + +.listBox { + overflow: auto; + flex: 1; +} + +.itemFocused { + background-color: var(--color-bg-high); + color: var(--color-text); +} + +.itemSelected { + color: var(--color-text-accent); + font-weight: var(--weight-bold); +} + +.searchField { + position: relative; + display: flex; + gap: var(--s-2); + + border-bottom: 1px solid var(--color-border); + border-radius: 0; + accent-color: var(--color-accent); + color: var(--color-text); + outline: none; + padding: var(--s-1-5) var(--s-1-5) calc(var(--s-0-5) + var(--s-2)) + var(--s-1-5); + margin-block-end: var(--s-1-5); +} + +.searchInput { + all: unset; + font-size: var(--font-xs); + font-weight: var(--weight-semi); + letter-spacing: 0.5px; + flex: 1; + min-width: 0; + padding-inline-end: calc(18px + var(--s-2)); + + &::-webkit-search-cancel-button { + display: none; + } + + &::placeholder { + color: var(--color-text-high); + } +} + +.searchClearButton { + position: absolute; + right: var(--s-2); + top: 50%; + transform: translateY(-50%); + background-color: transparent; + border: none; + padding: 0; + cursor: pointer; +} + +[data-empty] .searchClearButton { + visibility: hidden; +} + +.select { + width: 100%; + position: relative; + display: flex; + flex-direction: column; + gap: var(--s-1-5); +} diff --git a/app/components/elements/SelectShell.tsx b/app/components/elements/SelectShell.tsx new file mode 100644 index 000000000..91a9a8b30 --- /dev/null +++ b/app/components/elements/SelectShell.tsx @@ -0,0 +1,126 @@ +/** + * The select look shared by `SendouSelect` and `SearchSelect`: the trigger + * button, the popover with its search field, and the list box items' focus and + * selection states. Both selects render their own contents inside these. + */ + +import clsx from "clsx"; +import { ChevronsUpDown, Search, X } from "lucide-react"; +import type * as React from "react"; +import { + Button, + Input, + ListBox, + ListBoxItem, + type ListBoxItemProps, + Popover, + SearchField, + Select, + type SelectProps, +} from "react-aria-components"; +import styles from "./SelectShell.module.css"; + +export function SelectShell({ + className, + children, + ...rest +}: SelectProps & { children: React.ReactNode }) { + return ( + + ); +} + +export function SelectShellTrigger({ + ref, + children, +}: { + ref?: React.Ref; + children: React.ReactNode; +}) { + return ( + + ); +} + +export function SelectShellPopover({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}) { + return ( + {children} + ); +} + +export function SelectShellSearchField({ + placeholder, + inputClassName, + inputTestId, +}: { + placeholder?: string; + inputClassName?: string; + inputTestId?: string; +}) { + return ( + + + + + + ); +} + +export function SelectShellListBox({ + items, + className, + renderEmptyState, + children, +}: { + items?: Iterable; + className?: string; + renderEmptyState?: () => React.ReactNode; + children: React.ReactNode | ((item: T) => React.ReactNode); +}) { + return ( + + {children} + + ); +} + +/** list box item carrying the shared focus/selection styling */ +export function SelectShellItem({ + className, + ...rest +}: Omit & { className?: string }) { + return ( + + clsx(className, { + [styles.itemFocused]: isFocused, + [styles.itemSelected]: isSelected, + }) + } + /> + ); +} diff --git a/app/components/elements/Tabs.tsx b/app/components/elements/Tabs.tsx index 8529b0575..a74b7f1a4 100644 --- a/app/components/elements/Tabs.tsx +++ b/app/components/elements/Tabs.tsx @@ -12,7 +12,7 @@ import { } from "react-aria-components"; import { useMainContentWidth } from "~/hooks/useMainContentWidth"; -import buttonStyles from "./Button.module.css"; +import { ButtonLook } from "./Button"; import styles from "./Tabs.module.css"; interface SendouTabsProps extends TabsProps { @@ -103,14 +103,14 @@ export function SendouTab({ }: SendouTabProps) { return ( -
+ {icon} {children} {typeof number === "number" && number !== 0 && ( {number} )} {alert ? : null} -
+
); } diff --git a/app/components/elements/TeamSearch.tsx b/app/components/elements/TeamSearch.tsx index 07dd13a5c..9dad8cadc 100644 --- a/app/components/elements/TeamSearch.tsx +++ b/app/components/elements/TeamSearch.tsx @@ -1,10 +1,11 @@ -import clsx from "clsx"; import type * as React from "react"; -import { ListBoxItem, type SelectProps } from "react-aria-components"; +import type { SelectProps } from "react-aria-components"; import type { SearchLoaderData } from "~/features/search/routes/search"; -import { SearchSelect } from "./SearchSelect"; -import searchSelectStyles from "./SearchSelect.module.css"; -import selectStyles from "./Select.module.css"; +import { + SearchSelect, + SearchSelectItem, + SearchSelectItemLogo, +} from "./SearchSelect"; import teamSearchStyles from "./TeamSearch.module.css"; import { useEntitySearch } from "./useEntitySearch"; @@ -73,25 +74,19 @@ function parseTeamResults( function TeamItem({ item }: { item: TeamSearchResult }) { return ( - - clsx(searchSelectStyles.item, { - [selectStyles.itemFocused]: isFocused, - [selectStyles.itemSelected]: isSelected, - }) + testId="team-search-item" + leading={ + item.avatarUrl ? ( + + ) : ( +
+ ) } - data-testid="team-search-item" > - {item.avatarUrl ? ( - - ) : ( -
- )} -
- {item.name} -
- + {item.name} + ); } diff --git a/app/components/elements/TournamentSearch.tsx b/app/components/elements/TournamentSearch.tsx index fa06db074..fc12995be 100644 --- a/app/components/elements/TournamentSearch.tsx +++ b/app/components/elements/TournamentSearch.tsx @@ -1,13 +1,15 @@ -import clsx from "clsx"; import { sub } from "date-fns"; import type * as React from "react"; -import { ListBoxItem, type SelectProps } from "react-aria-components"; +import type { SelectProps } from "react-aria-components"; import type { TournamentSearchLoaderData } from "~/features/tournament/routes/to.search"; import { tournamentSearchSearchParams } from "~/features/tournament/tournament-search-params"; import { LocaleTime } from "../LocaleTime"; -import { SearchSelect } from "./SearchSelect"; -import searchSelectStyles from "./SearchSelect.module.css"; -import selectStyles from "./Select.module.css"; +import { + SearchSelect, + SearchSelectItem, + SearchSelectItemAdditionalText, + SearchSelectItemLogo, +} from "./SearchSelect"; import { useEntitySearch } from "./useEntitySearch"; export type TournamentSearchItem = NonNullable< @@ -90,20 +92,14 @@ function parseTournamentResults( function TournamentItem({ item }: { item: TournamentSearchItem }) { return ( - - clsx(searchSelectStyles.item, { - [selectStyles.itemFocused]: isFocused, - [selectStyles.itemSelected]: isSelected, - }) - } - data-testid="tournament-search-item" + testId="tournament-search-item" + leading={} > - -
- {item.name} + {item.name} + -
-
+ + ); } diff --git a/app/components/elements/UserSearch.tsx b/app/components/elements/UserSearch.tsx index ce690332c..66141e280 100644 --- a/app/components/elements/UserSearch.tsx +++ b/app/components/elements/UserSearch.tsx @@ -1,12 +1,13 @@ -import clsx from "clsx"; import * as React from "react"; -import { ListBoxItem, type SelectProps } from "react-aria-components"; +import type { SelectProps } from "react-aria-components"; import { useFetcher } from "react-router"; import type { SearchLoaderData } from "~/features/search/routes/search"; import { Avatar } from "../Avatar"; -import { SearchSelect } from "./SearchSelect"; -import searchSelectStyles from "./SearchSelect.module.css"; -import selectStyles from "./Select.module.css"; +import { + SearchSelect, + SearchSelectItem, + SearchSelectItemAdditionalText, +} from "./SearchSelect"; import { useEntitySearch } from "./useEntitySearch"; export type UserSearchResult = Extract< @@ -119,26 +120,18 @@ function UserItem({ item }: { item: UserSearchResult }) { }; return ( - - clsx(searchSelectStyles.item, { - [selectStyles.itemFocused]: isFocused, - [selectStyles.itemSelected]: isSelected, - }) - } - data-testid="user-search-item" + testId="user-search-item" + leading={} > - -
- {item.name} - {additionalText() ? ( -
- {additionalText()} -
- ) : null} -
-
+ {item.name} + {additionalText() ? ( + + {additionalText()} + + ) : null} + ); } diff --git a/app/components/layout/ChatSidebar.tsx b/app/components/layout/ChatSidebar.tsx index 3e538fcd6..5a6afb724 100644 --- a/app/components/layout/ChatSidebar.tsx +++ b/app/components/layout/ChatSidebar.tsx @@ -14,7 +14,14 @@ import { Chat } from "~/features/chat/components/Chat"; import { useChatContext } from "~/features/chat/useChatContext"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { useLayoutSize } from "~/hooks/useMainContentWidth"; -import sideNavStyles from "../SideNav.module.css"; +import { + NavIconContainer, + NavListButton, + NavListImage, + NavListSubtitle, + NavListTexts, + NavListTitle, +} from "../SideNav"; import styles from "./ChatSidebar.module.css"; export function ChatSidebar({ onClose }: { onClose?: () => void }) { @@ -38,9 +45,9 @@ function SidebarHeader({ onClose }: { onClose?: () => void }) { return (
-
+ -
+

{t("common:chat.sidebar.title")}

{onClose ? ( + ); })} @@ -210,30 +207,21 @@ function CombinedRoomListItem({ ); return ( - + ); } @@ -302,13 +290,7 @@ function SingleChatView({ onClose }: { onClose?: () => void }) { const headerContent = ( <> - {room?.imageUrl ? ( - - ) : null} + {room?.imageUrl ? : null}
- {primary.imageUrl ? ( - - ) : null} + {primary.imageUrl ? : null}
{resolveDatePlaceholders( diff --git a/app/components/layout/GlobalSearch.module.css b/app/components/layout/GlobalSearch.module.css index 11e6aaed6..de76fcba2 100644 --- a/app/components/layout/GlobalSearch.module.css +++ b/app/components/layout/GlobalSearch.module.css @@ -58,15 +58,6 @@ } } -@keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - .modal { width: 100%; max-width: 36rem; @@ -83,17 +74,6 @@ } } -@keyframes zoom-in-95 { - from { - opacity: 0; - transform: scale(0.95); - } - to { - opacity: 1; - transform: scale(1); - } -} - .dialog { outline: none; } @@ -145,36 +125,6 @@ flex-shrink: 0; } -.listBox { - max-height: 325px; - overflow-y: auto; - padding: var(--s-2); - outline: none; -} - -.listBoxItem { - display: block; - padding: var(--s-2) var(--s-3); - border-radius: var(--radius-field); - cursor: pointer; - font-size: var(--font-sm); - outline: none; - color: inherit; - text-decoration: none; - - &[data-hovered] { - background-color: var(--color-bg-high); - } - - &[data-focused] { - background-color: var(--color-bg-high); - } - - &[data-selected] { - background-color: var(--color-accent-low); - } -} - .searchTypeContainer { padding: var(--s-4); border-bottom: 1px solid var(--color-border); @@ -226,24 +176,12 @@ outline-offset: 1px; } -.resultItem { - display: flex; - align-items: center; - gap: var(--s-2); -} - .resultTexts { display: flex; flex-direction: column; min-width: 0; } -.resultName { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .resultSecondary { font-size: var(--font-xs); color: var(--color-text-high); @@ -257,46 +195,29 @@ object-fit: cover; } -.emptyState { - padding: var(--s-8); - text-align: center; - color: var(--color-text-high); - font-size: var(--font-sm); -} - -.weaponDestinationHeader { - display: flex; - align-items: center; - gap: var(--s-2); - padding: var(--s-3) var(--s-4); - border-bottom: 1px solid var(--color-border); -} - -.backButton { - display: flex; - align-items: center; - justify-content: center; - height: var(--field-size-sm); - aspect-ratio: 1 / 1; - border: 1px solid var(--color-border); - border-radius: var(--radius-field); - background-color: var(--color-bg); - color: var(--color-text-high); - cursor: pointer; - transition: background-color 0.15s; - - &:hover { - background-color: var(--color-bg-high); - } -} - -.selectedWeaponName { - font-weight: var(--weight-semi); -} - /** needs to go away so we have enough space even with both side panels open */ @media screen and (max-width: 1100px) { .searchKbd { display: none; } } + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes zoom-in-95 { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} diff --git a/app/components/layout/GlobalSearch.tsx b/app/components/layout/GlobalSearch.tsx index 56db215ce..adaf8c08d 100644 --- a/app/components/layout/GlobalSearch.tsx +++ b/app/components/layout/GlobalSearch.tsx @@ -6,8 +6,6 @@ import { Button, Dialog, DialogTrigger, - ListBox, - ListBoxItem, Modal, ModalOverlay, Radio, @@ -44,6 +42,13 @@ import { GLOBAL_SEARCH_TYPES as SEARCH_TYPES, type GlobalSearchType as SearchType, } from "./global-search-search-params"; +import { + SearchResultsEmptyState, + SearchResultsItem, + SearchResultsItemName, + SearchResultsItemRow, + SearchResultsListBox, +} from "./SearchResults"; import { filterWeaponResults, type SelectedWeapon, @@ -354,44 +359,43 @@ function GlobalSearchContent({ listBoxRef={listBoxRef} /> ) : ( - { if (!hasQuery) { return ( -
+ {t("common:search.hint")} -
+ ); } if (!isCurrentFetch) { return ( -
+ {t("common:search.searching")} -
+ ); } return ( -
+ {t("common:search.noResults")} -
+ ); }} > {results.map((result) => ( - - + ))} -
+ )}
); @@ -432,7 +436,7 @@ function ResultItem({ result }: { result: SearchResult }) { switch (result.type) { case "user": return ( -
+
- {result.name} + {result.name} {result.inGameName ? ( {result.inGameName} ) : null}
-
+ ); case "team": return ( -
+ - {result.name} -
+ {result.name} + ); case "organization": return ( -
+ - {result.name} -
+ {result.name} + ); case "tournament": return ( -
+ {result.logoUrl ? ( ) : null}
- {result.name} + {result.name}
-
+ ); } } diff --git a/app/components/layout/NotificationPopover.tsx b/app/components/layout/NotificationPopover.tsx index a15838ab4..a8049cd32 100644 --- a/app/components/layout/NotificationPopover.tsx +++ b/app/components/layout/NotificationPopover.tsx @@ -1,7 +1,10 @@ +import clsx from "clsx"; import { Bell, ChevronRight } from "lucide-react"; import * as React from "react"; +import { Button } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; +import { SendouPopover } from "~/components/elements/Popover"; import { NotificationItem, NotificationItemDivider, @@ -39,6 +42,34 @@ export function useNotifications() { return { notifications, unseenIds, showUnseenDot }; } +export function NotificationPopover({ + notifications, + unseenIds, + triggerClassName, +}: { + notifications: LoaderNotification[]; + unseenIds: number[]; + triggerClassName?: string; +}) { + return ( + + + + } + popoverClassName={clsx(styles.popoverContainer, { + [styles.noNotificationsContainer]: notifications.length === 0, + })} + > + + + ); +} + export function NotificationContent({ notifications, unseenIds, diff --git a/app/components/layout/SearchResults.module.css b/app/components/layout/SearchResults.module.css new file mode 100644 index 000000000..cc9ea064c --- /dev/null +++ b/app/components/layout/SearchResults.module.css @@ -0,0 +1,48 @@ +.listBox { + max-height: 325px; + overflow-y: auto; + padding: var(--s-2); + outline: none; +} + +.listBoxItem { + display: block; + padding: var(--s-2) var(--s-3); + border-radius: var(--radius-field); + cursor: pointer; + font-size: var(--font-sm); + outline: none; + color: inherit; + text-decoration: none; + + &[data-hovered] { + background-color: var(--color-bg-high); + } + + &[data-focused] { + background-color: var(--color-bg-high); + } + + &[data-selected] { + background-color: var(--color-accent-low); + } +} + +.resultItem { + display: flex; + align-items: center; + gap: var(--s-2); +} + +.resultName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.emptyState { + padding: var(--s-8); + text-align: center; + color: var(--color-text-high); + font-size: var(--font-sm); +} diff --git a/app/components/layout/SearchResults.tsx b/app/components/layout/SearchResults.tsx new file mode 100644 index 000000000..16e18d541 --- /dev/null +++ b/app/components/layout/SearchResults.tsx @@ -0,0 +1,57 @@ +/** + * The result list chrome shared by the global search and its weapon sub-view: + * the list box, its items and the empty state shown while there is nothing to + * list. + */ + +import clsx from "clsx"; +import type * as React from "react"; +import { + ListBox, + ListBoxItem, + type ListBoxItemProps, + type ListBoxProps, +} from "react-aria-components"; +import styles from "./SearchResults.module.css"; + +export function SearchResultsListBox({ + className, + ref, + children, + ...rest +}: ListBoxProps & { ref?: React.Ref }) { + return ( + + {children} + + ); +} + +export function SearchResultsEmptyState({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} + +export function SearchResultsItem(props: ListBoxItemProps) { + return ; +} + +/** the icon + texts row inside one result */ +export function SearchResultsItemRow({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} + +export function SearchResultsItemName({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/app/components/layout/WeaponSearch.module.css b/app/components/layout/WeaponSearch.module.css new file mode 100644 index 000000000..49316b988 --- /dev/null +++ b/app/components/layout/WeaponSearch.module.css @@ -0,0 +1,29 @@ +.weaponDestinationHeader { + display: flex; + align-items: center; + gap: var(--s-2); + padding: var(--s-3) var(--s-4); + border-bottom: 1px solid var(--color-border); +} + +.backButton { + display: flex; + align-items: center; + justify-content: center; + height: var(--field-size-sm); + aspect-ratio: 1 / 1; + border: 1px solid var(--color-border); + border-radius: var(--radius-field); + background-color: var(--color-bg); + color: var(--color-text-high); + cursor: pointer; + transition: background-color 0.15s; + + &:hover { + background-color: var(--color-bg-high); + } +} + +.selectedWeaponName { + font-weight: var(--weight-semi); +} diff --git a/app/components/layout/WeaponSearch.tsx b/app/components/layout/WeaponSearch.tsx index dd677a5ed..71f4fe610 100644 --- a/app/components/layout/WeaponSearch.tsx +++ b/app/components/layout/WeaponSearch.tsx @@ -1,4 +1,3 @@ -import { clsx } from "clsx"; import type { Namespace, TFunction } from "i18next"; import { Calculator, @@ -12,7 +11,6 @@ import { Videotape, } from "lucide-react"; import type * as React from "react"; -import { ListBox, ListBoxItem } from "react-aria-components"; import { useTranslation } from "react-i18next"; import { Image } from "~/components/Image"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; @@ -33,7 +31,14 @@ import { weaponBuildStatsPage, weaponParamsPage, } from "~/utils/urls"; -import styles from "./GlobalSearch.module.css"; +import { + SearchResultsEmptyState, + SearchResultsItem, + SearchResultsItemName, + SearchResultsItemRow, + SearchResultsListBox, +} from "./SearchResults"; +import styles from "./WeaponSearch.module.css"; const WEAPON_DESTINATIONS = [ "builds", @@ -154,104 +159,101 @@ export function WeaponDestinationMenu({ {selectedWeapon.name}
- - -
+ - + {t("common:pages.builds")} - -
-
- + + + -
+ - + {t("common:pages.popularBuilds")} - -
-
- + + + -
+ - + {t("common:pages.abilityStats")} - -
-
- + + + -
+ - + {t("common:pages.analyzer")} - -
-
- + + + -
+ - + {t("common:pages.params")} - -
-
- + + + -
+ - {t("common:pages.vods")} -
-
- + {t("common:pages.vods")} + + + + -
+ - {t("common:pages.art")} -
-
- + {t("common:pages.art")} + + + + -
+ - {t("common:pages.lfg")} -
-
-
+ + {t("common:pages.lfg")} + + + +
); } @@ -276,34 +278,35 @@ export function WeaponResultsList({ const showHint = !hasQuery && recentWeapons.length === 0; return ( - showNoResults ? ( -
+ {t("common:search.noResults")} -
+ ) : showHint ? ( -
{t("common:search.hint")}
+ + {t("common:search.hint")} + ) : null } > {displayedWeapons.map((weapon) => ( - -
+ - {weapon.name} -
-
+ {weapon.name} + + ))} -
+ ); } diff --git a/app/components/layout/index.module.css b/app/components/layout/index.module.css index 6ad0b7085..ac7530413 100644 --- a/app/components/layout/index.module.css +++ b/app/components/layout/index.module.css @@ -334,3 +334,72 @@ z-index: 20; } } + +.sideNavFooterUser { + display: flex; + align-items: center; + gap: var(--s-2); + color: var(--color-text); + text-decoration: none; + font-size: var(--font-xs); + font-weight: var(--weight-semi); + border-radius: var(--radius-field); + padding: var(--s-1); + margin-left: calc(-1 * var(--s-1)); + flex: 1; + min-width: 0; + + &:hover { + background-color: var(--color-bg-high); + } +} + +.sideNavFooterUsername { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sideNavFooterActions { + display: flex; + align-items: center; + gap: var(--s-0-5); + margin-left: auto; +} + +.sideNavFooterButton { + display: grid; + place-items: center; + background-color: transparent; + width: var(--field-size-sm); + height: var(--field-size-sm); + padding: 0; + border: none; + border-radius: var(--radius-field); + color: var(--color-text-high); + cursor: pointer; + + &:hover { + background-color: var(--color-bg-high); + color: var(--color-text); + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: -2px; + } + + & svg { + width: 18px; + height: 18px; + } +} + +.sideNavFooterNotification { + position: relative; +} + +.sideNavFooterUnseenDot { + --dot-top: 2px; + --dot-right: 2px; +} diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx index e20923dc1..91ca2220f 100644 --- a/app/components/layout/index.tsx +++ b/app/components/layout/index.tsx @@ -1,7 +1,6 @@ import clsx from "clsx"; import { isToday, isTomorrow } from "date-fns"; import { - Bell, Calendar, ChevronRight, LogIn, @@ -12,7 +11,6 @@ import { } from "lucide-react"; import * as React from "react"; import { - Button, Dialog, DialogTrigger, Modal, @@ -44,21 +42,18 @@ import { } from "~/utils/urls"; import { Avatar, generateIdenticon } from "../Avatar"; import { SendouButton } from "../elements/Button"; -import { SendouPopover } from "../elements/Popover"; import { FuseZone } from "../fuse/Fuse"; import { Image } from "../Image"; import { MobileNav } from "../MobileNav"; import { NotificationDot } from "../NotificationDot"; import { ListLink, SideNav, SideNavFooter, SideNavHeader } from "../SideNav"; -import sideNavStyles from "../SideNav.module.css"; import { StreamListItems } from "../StreamListItems"; import { ChatSidebar } from "./ChatSidebar"; import { Footer } from "./Footer"; import styles from "./index.module.css"; import { LogInButtonContainer } from "./LogInButtonContainer"; import { authErrorSearchParams } from "./layout-search-params"; -import { NotificationContent, useNotifications } from "./NotificationPopover"; -import notificationPopoverStyles from "./NotificationPopover.module.css"; +import { NotificationPopover, useNotifications } from "./NotificationPopover"; import { TopNavMenus } from "./TopNavMenus"; import { TopRightButtons } from "./TopRightButtons"; @@ -676,52 +671,30 @@ function SideNavUserPanel() { if (user) { return ( <> - + - - {user.username} - + {user.username} -
+
{notifications ? (
{showUnseenDot ? ( ) : null} - - - - } - popoverClassName={clsx( - notificationPopoverStyles.popoverContainer, - { - [notificationPopoverStyles.noNotificationsContainer]: - notifications.length === 0, - }, - )} - > - - +
) : null} - +
@@ -736,8 +709,8 @@ function SideNavUserPanel() { {t("header.login.discord")} -
- +
+
diff --git a/app/components/match-page/MatchBanner.tsx b/app/components/match-page/MatchBanner.tsx index c986270de..4d5b7980f 100644 --- a/app/components/match-page/MatchBanner.tsx +++ b/app/components/match-page/MatchBanner.tsx @@ -214,3 +214,19 @@ function ScreenNotice({ screenLegal }: { screenLegal: boolean }) { ); } + +/** + * Trigger for the small popovers hung off a banner's info row, e.g. who voted + * for the map or which team picked it. + */ +export function MatchBannerInfoBadge({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/app/components/match-page/MatchBannerTimer.tsx b/app/components/match-page/MatchBannerTimer.tsx index 0f7952416..9d1bbaf01 100644 --- a/app/components/match-page/MatchBannerTimer.tsx +++ b/app/components/match-page/MatchBannerTimer.tsx @@ -1,6 +1,5 @@ import { useTranslation } from "react-i18next"; import { useHydrated } from "~/hooks/useHydrated"; -import styles from "./MatchBannerTopRow.module.css"; const MAX_MINUTES = 60; @@ -35,8 +34,11 @@ export function MatchBannerTimer({ time }: MatchBannerTimerProps) { : minuteFormatter.format(minutes); return ( -
-