* Add ellipsis icon * Reduce size of tokens * Move UpdateToast to toasts folder * Update variables.scss * Add reps for grid objects These reps act like the existing PartyRep except for Characters and Summons, as well as a new component just for Weapons. They only render the grid of objects and nothing else. Eventually PartyRep will use WeaponRep * Added RepSegment This is a Character, Weapon or Summon rep wrapped with an input and label for use in a SegmentedControl * Modify PartySegmentedControl to use RepSegments This will not work on mobile yet, where it should gracefully degrade to a normal SegmentedControl with only text * Extract URL copied and Remixed toasts into files * Extract delete team alert into a file Also, to support this: * Added `Destructive` class to Button * Added `primaryActionClassName` prop to Alert * Added an alert for when remixing teams * Began refactoring PartyDetails into several files * PartyHeader will live at the top, above the new segmented control * PartyDetails stays below, only showing remixed teams and the description * PartyDropdown handles the new ... menu * Remove duplicated code This is description and remix code that is still in `PartyDetails` * Small fixes for weapon grid * Add placeholder image for guidebooks * Add localizations * Add Guidebook type and update other types * Update gitignore Don't commit guidebook images * Indicate if a dialog is scrollable We had broken paging in the infinite scroll component. Turning off "scrolling" at the dialog levels fixes it without adding scrollbars in environments that persistently show them * Add ExtraContainer This is the purple container that will contain additional weapons and sephira guidebooks * Move ExtraWeapons to ExtraWeaponsGrid And put it in ExtraContainer * Added GuidebooksGrid and GuidebookUnit These are the display components for Guidebooks in the WeaponGrid * Visual adjustments to summon grid * Add Empty class to weapons when unit is unfilled * Implement GuidebooksGrid in WeaponGrid * Remove extra switch * Remove old dependencies and props * Implement searching for/adding guidebooks to party * Update styles * Fix dependency * Properly determine when extra container should display * Change to 1-indexing for guidebooks * Add support for removing guidebooks * Display guidebook validation error * Move read only buttons to PartyHeader Also broke up tokens and made them easier to render * Add guidebooks to DetailsObject * Add raid placeholder string to locale * Update .gitignore * Update and reorganize localization files * Update types Added RaidGroup and updated Raid, then updated dependent types and objects * Update dependencies * Update react and react-dom to at least 18.0.0 * Install cmdk * Rename Arrow.svg to Chevron.svg Also added a new Arrow.svg with a stem * Add api call for raidGroups and update pages Pages fetch raids and store them in the app state. We needed to update this to pull raid groups instead * Update SegmentedControl component * Add className and blended properties * Segment gets flex-grow * Update Select component * data-placeholder style should match only if true * Adjust corner radius to match cards instead of inputs * Fix classNames call in SelectItem * Remove raid prop from Party * Add Popover component * Popover is a wrapper of Radix's Popover component that we will use to wrap the combobox. * Move styles that were in PopoverContent.scss to Popover.scss * Add Command component The Command component is a wrapper over CMDK's Command component. Pretty much every object in that library is wrapped here. We will use this for the guts of our combobox. * Add RaidCombobox and RaidItem components * RaidCombobox combines Popover and Command to create an experience where users can browse through raids by section, search them and sort them. * RaidItem is effectively a copy-paste of SelectItem using CommandItem, adding some raid-specific styles and elements * Updates themes and variables * Replace RaidDropdown with RaidCombobox * Add small shadow to Tooltip * Update side offset for Popover * Update CharLimitedFieldset class name * Add clear button to Combobox input * It only shows up when there is text in the input * Clicking it clears the text in the input * It uses CharLimitedFieldset's classes * ChatGPT helped me refactor RaidCombobox * Further refactoring of RaidCombobox * Deploy content update (#309) * Update the updates page with new items (#306) * Add Nier and Estarriola uncaps (#308) * Update the updates page with new items (#306) (#307) * Update .gitignore * Add Nier and Estarriola uncaps * Fix uncaps treated as new characters * Make combobox keyboard accessible * Style updates * Refactor accessibility code * Add translation for "Selected" text * Change selects to be poppers for consistency We can't make the new Raid combobox appear over the input like the macOS behavior, so we change all selects to be normal popper behavior * Set raid groups on teams page * Implement in FilterBar * Fix styles for combobox input * Remove RaidDropdown component * Update index.scss * Remove preview when on mobile sizes * Fix some mobile styles * Add farming raid option * Increase height slightly
568 lines
15 KiB
TypeScript
568 lines
15 KiB
TypeScript
import { createRef, useCallback, useEffect, useState, useRef } from 'react'
|
|
import { useRouter } from 'next/router'
|
|
import { useTranslation } from 'react-i18next'
|
|
import classNames from 'classnames'
|
|
|
|
import { Command, CommandGroup, CommandInput } from 'cmdk'
|
|
import Popover from '~components/common/Popover'
|
|
import SegmentedControl from '~components/common/SegmentedControl'
|
|
import Segment from '~components/common/Segment'
|
|
import RaidItem from '~components/raids/RaidItem'
|
|
import Tooltip from '~components/common/Tooltip'
|
|
|
|
import api from '~utils/api'
|
|
import { appState } from '~utils/appState'
|
|
|
|
interface Props {
|
|
showAllRaidsOption: boolean
|
|
currentRaid?: Raid
|
|
defaultRaid?: Raid
|
|
minimal?: boolean
|
|
onChange?: (raid?: Raid) => void
|
|
onBlur?: (event: React.ChangeEvent<HTMLSelectElement>) => void
|
|
}
|
|
|
|
import Button from '~components/common/Button'
|
|
import ArrowIcon from '~public/icons/Arrow.svg'
|
|
import CrossIcon from '~public/icons/Cross.svg'
|
|
|
|
import './index.scss'
|
|
|
|
const NUM_SECTIONS = 3
|
|
const NUM_ELEMENTS = 5
|
|
|
|
enum Sort {
|
|
ASCENDING,
|
|
DESCENDING,
|
|
}
|
|
|
|
// Set up empty raid for "All raids"
|
|
const untitledGroup: RaidGroup = {
|
|
id: '0',
|
|
name: {
|
|
en: '',
|
|
ja: '',
|
|
},
|
|
section: 0,
|
|
order: 0,
|
|
extra: false,
|
|
raids: [],
|
|
difficulty: 0,
|
|
hl: false,
|
|
}
|
|
|
|
// Set up empty raid for "All raids"
|
|
const allRaidsOption: Raid = {
|
|
id: '0',
|
|
name: {
|
|
en: 'All battles',
|
|
ja: '全てのバトル',
|
|
},
|
|
group: untitledGroup,
|
|
slug: 'all',
|
|
level: 0,
|
|
element: 0,
|
|
}
|
|
|
|
const RaidCombobox = (props: Props) => {
|
|
// Set up router for locale
|
|
const router = useRouter()
|
|
const locale = router.locale || 'en'
|
|
|
|
// Set up translations
|
|
const { t } = useTranslation('common')
|
|
|
|
// Component state
|
|
const [open, setOpen] = useState(false)
|
|
const [sort, setSort] = useState<Sort>(Sort.DESCENDING)
|
|
const [scrolled, setScrolled] = useState(false)
|
|
|
|
// Data state
|
|
const [currentSection, setCurrentSection] = useState(1)
|
|
const [query, setQuery] = useState('')
|
|
const [sections, setSections] = useState<RaidGroup[][]>()
|
|
const [currentRaid, setCurrentRaid] = useState<Raid>()
|
|
const [tabIndex, setTabIndex] = useState(NUM_ELEMENTS + 1)
|
|
|
|
// Data
|
|
const [farmingRaid, setFarmingRaid] = useState<Raid>()
|
|
|
|
// Refs
|
|
const listRef = createRef<HTMLDivElement>()
|
|
const inputRef = createRef<HTMLInputElement>()
|
|
const sortButtonRef = createRef<HTMLButtonElement>()
|
|
|
|
// ----------------------------------------------
|
|
// Methods: Lifecycle Hooks
|
|
// ----------------------------------------------
|
|
|
|
// Fetch all raids on mount
|
|
useEffect(() => {
|
|
api.raidGroups().then((response) => sortGroups(response.data))
|
|
}, [])
|
|
|
|
// Set current raid and section when the component mounts
|
|
useEffect(() => {
|
|
if (appState.party.raid) {
|
|
setCurrentRaid(appState.party.raid)
|
|
setCurrentSection(appState.party.raid.group.section)
|
|
} else if (props.showAllRaidsOption && !currentRaid) {
|
|
setCurrentRaid(allRaidsOption)
|
|
}
|
|
}, [])
|
|
|
|
// Set current raid and section when the current raid changes
|
|
useEffect(() => {
|
|
if (props.currentRaid) {
|
|
console.log('We are here with a raid')
|
|
setCurrentRaid(props.currentRaid)
|
|
setCurrentSection(props.currentRaid.group.section)
|
|
}
|
|
}, [props.currentRaid])
|
|
|
|
// Scroll to the top of the list when the user switches tabs
|
|
useEffect(() => {
|
|
if (listRef.current) {
|
|
listRef.current.scrollTop = 0
|
|
}
|
|
}, [currentSection])
|
|
|
|
useEffect(() => {
|
|
setTabIndex(NUM_ELEMENTS + 1)
|
|
}, [currentSection])
|
|
|
|
// ----------------------------------------------
|
|
// Methods: Event Handlers
|
|
// ----------------------------------------------
|
|
|
|
// Handle Escape key press event
|
|
const handleEscapeKeyPressed = useCallback(() => {
|
|
if (listRef.current) {
|
|
listRef.current.focus()
|
|
}
|
|
}, [open, currentRaid, sortButtonRef])
|
|
|
|
// Handle Arrow key press event by focusing the list item above or below the current one based on the direction
|
|
const handleArrowKeyPressed = useCallback(
|
|
(direction: 'Up' | 'Down') => {
|
|
const current = listRef.current?.querySelector(
|
|
'.Raid:focus'
|
|
) as HTMLElement | null
|
|
|
|
if (current) {
|
|
let next: Element | null | undefined
|
|
|
|
if (direction === 'Down' && !current.nextElementSibling) {
|
|
const nextParent =
|
|
current.parentElement?.parentElement?.nextElementSibling
|
|
next = nextParent?.querySelector('.Raid')
|
|
} else if (direction === 'Up' && !current.previousElementSibling) {
|
|
const previousParent =
|
|
current.parentElement?.parentElement?.previousElementSibling
|
|
next = previousParent?.querySelector('.Raid:last-child')
|
|
} else {
|
|
next =
|
|
direction === 'Up'
|
|
? current.previousElementSibling
|
|
: current.nextElementSibling
|
|
}
|
|
|
|
if (next) {
|
|
;(next as HTMLElement).focus()
|
|
}
|
|
}
|
|
},
|
|
[open, currentRaid, listRef]
|
|
)
|
|
|
|
// Scroll to an item in the list when it is selected
|
|
const scrollToItem = useCallback(
|
|
(node) => {
|
|
if (!scrolled && open && currentRaid && listRef.current && node) {
|
|
const { top: listTop } = listRef.current.getBoundingClientRect()
|
|
const { top: itemTop } = node.getBoundingClientRect()
|
|
|
|
listRef.current.scrollTop = itemTop - listTop
|
|
console.log('Focusing node')
|
|
node.focus()
|
|
setScrolled(true)
|
|
}
|
|
},
|
|
[scrolled, open, currentRaid, listRef]
|
|
)
|
|
|
|
// Reverse the sort order
|
|
function reverseSort() {
|
|
if (sort === Sort.ASCENDING) setSort(Sort.DESCENDING)
|
|
else setSort(Sort.ASCENDING)
|
|
}
|
|
|
|
// Sorts the raid groups into sections
|
|
const sortGroups = useCallback(
|
|
(groups: RaidGroup[]) => {
|
|
const sections: [RaidGroup[], RaidGroup[], RaidGroup[]] = [[], [], []]
|
|
|
|
groups.forEach((group) => {
|
|
if (group.section > 0) sections[group.section - 1].push(group)
|
|
})
|
|
|
|
setFarmingRaid(groups[0].raids[0])
|
|
|
|
setSections(sections)
|
|
},
|
|
[setSections]
|
|
)
|
|
|
|
const handleSortButtonKeyDown = (
|
|
event: React.KeyboardEvent<HTMLButtonElement>
|
|
) => {
|
|
// If the tab key is pressed without the Shift key, focus the raid list
|
|
if (event.key === 'Tab' && !event.shiftKey) {
|
|
if (listRef.current) {
|
|
listRef.current.focus()
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleListKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
if (event.key === 'Tab' && !event.shiftKey) {
|
|
event.preventDefault()
|
|
if (inputRef.current) {
|
|
inputRef.current.focus()
|
|
}
|
|
} else if (event.key === 'Tab' && event.shiftKey) {
|
|
event.preventDefault()
|
|
if (sortButtonRef.current) {
|
|
sortButtonRef.current.focus()
|
|
}
|
|
}
|
|
|
|
// If the enter key is pressed, focus the first raid item in the list
|
|
else if (event.key === 'Enter') {
|
|
event.preventDefault()
|
|
if (listRef.current) {
|
|
const raid = listRef.current.querySelector('.Raid')
|
|
if (raid) {
|
|
;(raid as HTMLElement).focus()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle value change for the raid selection
|
|
function handleValueChange(raid: Raid) {
|
|
setCurrentRaid(raid)
|
|
setOpen(false)
|
|
setScrolled(false)
|
|
if (props.onChange) props.onChange(raid)
|
|
}
|
|
|
|
// Toggle the open state of the combobox
|
|
function toggleOpen() {
|
|
if (open) {
|
|
if (currentRaid && currentRaid.slug !== 'all') {
|
|
setCurrentSection(currentRaid.group.section)
|
|
}
|
|
setScrolled(false)
|
|
}
|
|
setOpen(!open)
|
|
}
|
|
|
|
// Clear the search query
|
|
function clearSearch() {
|
|
setQuery('')
|
|
}
|
|
|
|
// ----------------------------------------------
|
|
// Methods: Rendering
|
|
// ----------------------------------------------
|
|
|
|
// Renders each raid section
|
|
function renderRaidSections() {
|
|
return Array.from({ length: NUM_SECTIONS }, (_, i) => renderRaidSection(i))
|
|
}
|
|
|
|
// Renders the specified raid section
|
|
function renderRaidSection(section: number) {
|
|
const currentSection = sections?.[section]
|
|
if (!currentSection) return
|
|
|
|
const sortedGroups = currentSection.sort((a, b) => {
|
|
return sort === Sort.ASCENDING ? a.order - b.order : b.order - a.order
|
|
})
|
|
|
|
return sortedGroups.map((group, i) => renderRaidGroup(section, i))
|
|
}
|
|
|
|
// Renders the specified raid group
|
|
function renderRaidGroup(section: number, index: number) {
|
|
if (!sections?.[section]?.[index]) return
|
|
|
|
const group = sections[section][index]
|
|
const options = generateRaidItems(group.raids)
|
|
|
|
const groupClassName = classNames({
|
|
CommandGroup: true,
|
|
Hidden: group.section !== currentSection,
|
|
})
|
|
|
|
const heading = (
|
|
<div className="Label">
|
|
{group.name[locale]}
|
|
<div className="Separator" />
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<CommandGroup
|
|
data-section={group.section}
|
|
className={groupClassName}
|
|
key={group.name[locale].toLowerCase().replace(' ', '-')}
|
|
heading={heading}
|
|
>
|
|
{options}
|
|
</CommandGroup>
|
|
)
|
|
}
|
|
|
|
// Render the ungrouped raid group
|
|
function renderUngroupedRaids() {
|
|
let ungroupedRaids = farmingRaid ? [farmingRaid] : []
|
|
|
|
if (props.showAllRaidsOption) {
|
|
ungroupedRaids.push(allRaidsOption)
|
|
}
|
|
|
|
const options = generateRaidItems(ungroupedRaids)
|
|
|
|
return (
|
|
<CommandGroup
|
|
data-section={untitledGroup.section}
|
|
className={classNames({
|
|
CommandGroup: true,
|
|
})}
|
|
key="ungrouped-raids"
|
|
>
|
|
{options}
|
|
</CommandGroup>
|
|
)
|
|
}
|
|
|
|
// Generates a list of RaidItem components from the specified raids
|
|
function generateRaidItems(raids: Raid[]) {
|
|
return raids
|
|
.sort((a, b) => {
|
|
if (a.element > 0 && b.element > 0) return a.element - b.element
|
|
if (a.name.en.includes('NM') && b.name.en.includes('NM'))
|
|
return a.level - b.level
|
|
return a.name.en.localeCompare(b.name.en)
|
|
})
|
|
.map((item, i) => renderRaidItem(item, i))
|
|
}
|
|
|
|
// Renders a RaidItem component for the specified raid
|
|
function renderRaidItem(raid: Raid, key: number) {
|
|
const isSelected = currentRaid?.id === raid.id
|
|
const isRef = isSelected ? scrollToItem : undefined
|
|
const imageUrl = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/raids/${raid.slug}.png`
|
|
|
|
return (
|
|
<RaidItem
|
|
className={isSelected ? 'Selected' : ''}
|
|
icon={{ alt: raid.name[locale], src: imageUrl }}
|
|
extra={raid.group.extra}
|
|
key={key}
|
|
selected={isSelected}
|
|
ref={isRef}
|
|
role="listitem"
|
|
tabIndex={0}
|
|
value={raid.slug}
|
|
onEscapeKeyPressed={handleEscapeKeyPressed}
|
|
onArrowKeyPressed={handleArrowKeyPressed}
|
|
onSelect={() => handleValueChange(raid)}
|
|
>
|
|
{raid.name[locale]}
|
|
</RaidItem>
|
|
)
|
|
}
|
|
|
|
// Renders a SegmentedControl component for selecting raid sections.
|
|
function renderSegmentedControl() {
|
|
return (
|
|
<SegmentedControl blended={true}>
|
|
<Segment
|
|
groupName="raid_section"
|
|
name="events"
|
|
selected={currentSection === 2}
|
|
tabIndex={2}
|
|
onClick={() => setCurrentSection(2)}
|
|
>
|
|
{t('raids.sections.events')}
|
|
</Segment>
|
|
<Segment
|
|
groupName="raid_section"
|
|
name="raids"
|
|
selected={currentSection === 1}
|
|
tabIndex={3}
|
|
onClick={() => setCurrentSection(1)}
|
|
>
|
|
{t('raids.sections.raids')}
|
|
</Segment>
|
|
<Segment
|
|
groupName="raid_section"
|
|
name="solo"
|
|
selected={currentSection === 3}
|
|
tabIndex={4}
|
|
onClick={() => setCurrentSection(3)}
|
|
>
|
|
{t('raids.sections.solo')}
|
|
</Segment>
|
|
</SegmentedControl>
|
|
)
|
|
}
|
|
|
|
// Renders a Button for sorting raids and a Tooltip for explaining what it does.
|
|
function renderSortButton() {
|
|
return (
|
|
<Tooltip
|
|
content={
|
|
sort === Sort.ASCENDING
|
|
? 'Lower difficulty battles first'
|
|
: 'Higher difficulty battles first'
|
|
}
|
|
>
|
|
<Button
|
|
blended={true}
|
|
buttonSize="small"
|
|
leftAccessoryIcon={<ArrowIcon />}
|
|
leftAccessoryClassName={sort === Sort.DESCENDING ? 'Flipped' : ''}
|
|
onClick={reverseSort}
|
|
onKeyDown={handleSortButtonKeyDown}
|
|
ref={sortButtonRef}
|
|
tabIndex={5}
|
|
/>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
|
|
// Renders the content for the Popover trigger.
|
|
function renderTriggerContent() {
|
|
if (currentRaid) {
|
|
const element = (
|
|
<>
|
|
{!props.minimal ? (
|
|
<div className="Info">
|
|
<span className="Group">{currentRaid.group.name[locale]}</span>
|
|
<span className="Separator">/</span>
|
|
<span className={classNames({ Raid: true }, linkClass)}>
|
|
{currentRaid.name[locale]}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<span className={classNames({ Raid: true }, linkClass)}>
|
|
{currentRaid.name[locale]}
|
|
</span>
|
|
)}
|
|
|
|
{currentRaid.group.extra && !props.minimal && (
|
|
<i className="ExtraIndicator">EX</i>
|
|
)}
|
|
</>
|
|
)
|
|
|
|
return {
|
|
element,
|
|
rawValue: currentRaid.id,
|
|
}
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
// Renders the search input for the raid combobox
|
|
function renderSearchInput() {
|
|
return (
|
|
<div className="Bound Joined">
|
|
<CommandInput
|
|
className="Input"
|
|
placeholder={t('search.placeholders.raid')}
|
|
tabIndex={1}
|
|
ref={inputRef}
|
|
value={query}
|
|
onValueChange={setQuery}
|
|
/>
|
|
<div
|
|
className={classNames({
|
|
Button: true,
|
|
Clear: true,
|
|
Visible: query.length > 0,
|
|
})}
|
|
onClick={clearSearch}
|
|
>
|
|
<CrossIcon />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ----------------------------------------------
|
|
// Methods: Utility
|
|
// ----------------------------------------------
|
|
function slugToRaid(slug: string) {
|
|
return appState.raidGroups
|
|
.filter((group) => group.section > 0)
|
|
.flatMap((group) => group.raids)
|
|
.find((raid) => raid.slug === slug)
|
|
}
|
|
|
|
const linkClass = classNames({
|
|
wind: currentRaid && currentRaid.element == 1,
|
|
fire: currentRaid && currentRaid.element == 2,
|
|
water: currentRaid && currentRaid.element == 3,
|
|
earth: currentRaid && currentRaid.element == 4,
|
|
dark: currentRaid && currentRaid.element == 5,
|
|
light: currentRaid && currentRaid.element == 6,
|
|
})
|
|
|
|
// ----------------------------------------------
|
|
// Render
|
|
// ----------------------------------------------
|
|
return (
|
|
<Popover
|
|
className="Flush"
|
|
open={open}
|
|
onOpenChange={toggleOpen}
|
|
placeholder={t('raids.placeholder')}
|
|
trigger={{ className: 'Raid' }}
|
|
value={renderTriggerContent()}
|
|
>
|
|
<Command className="Raid Combobox">
|
|
<div className="Header">
|
|
{renderSearchInput()}
|
|
{!query && (
|
|
<div className="Controls">
|
|
{renderSegmentedControl()}
|
|
{renderSortButton()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={classNames({ Raids: true, Searching: query !== '' })}
|
|
ref={listRef}
|
|
role="listbox"
|
|
tabIndex={6}
|
|
onKeyDown={handleListKeyDown}
|
|
>
|
|
{renderUngroupedRaids()}
|
|
{renderRaidSections()}
|
|
</div>
|
|
</Command>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
RaidCombobox.defaultProps = {
|
|
minimal: false,
|
|
}
|
|
|
|
export default RaidCombobox
|