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
This commit is contained in:
parent
f8d2ccd012
commit
d622d79f19
4 changed files with 592 additions and 0 deletions
151
components/raids/RaidCombobox/index.scss
Normal file
151
components/raids/RaidCombobox/index.scss
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
.DetailsWrapper .PartyDetails.Editable .Raid.SelectTrigger {
|
||||||
|
display: flex;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 11px;
|
||||||
|
min-height: 51px;
|
||||||
|
|
||||||
|
.Value {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-half;
|
||||||
|
|
||||||
|
.Info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-half;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExtraIndicator {
|
||||||
|
background: var(--extra-purple-bg);
|
||||||
|
border-radius: $full-corner;
|
||||||
|
color: var(--extra-purple-text);
|
||||||
|
display: flex;
|
||||||
|
font-weight: $bold;
|
||||||
|
font-size: $font-tiny;
|
||||||
|
width: $unit-3x;
|
||||||
|
height: $unit-3x;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Group,
|
||||||
|
.Separator {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Raid.wind {
|
||||||
|
color: var(--wind-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Raid.fire {
|
||||||
|
color: var(--fire-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Raid.water {
|
||||||
|
color: var(--water-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Raid.earth {
|
||||||
|
color: var(--earth-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Raid.dark {
|
||||||
|
color: var(--dark-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Raid.light {
|
||||||
|
color: var(--light-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.Combobox.Raid {
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Temporary values */
|
||||||
|
|
||||||
|
.Header {
|
||||||
|
background: var(--dialog-bg);
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.Controls {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
.Button.Blended.small {
|
||||||
|
padding: $unit ($unit * 1.25);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-contained-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.Flipped {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.SegmentedControlWrapper {
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
.SegmentedControl {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.Raids {
|
||||||
|
height: 40vh;
|
||||||
|
overflow-y: scroll;
|
||||||
|
padding: 0 $unit;
|
||||||
|
|
||||||
|
&.Searching {
|
||||||
|
.CommandGroup {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
|
||||||
|
.Label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.SelectItem {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CommandGroup.Hidden {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CommandGroup {
|
||||||
|
&.Hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Label {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $medium;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit $unit-2x $unit-half ($unit * 1.5);
|
||||||
|
|
||||||
|
.Separator {
|
||||||
|
background: var(--select-separator);
|
||||||
|
border-radius: 1px;
|
||||||
|
display: block;
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
331
components/raids/RaidCombobox/index.tsx
Normal file
331
components/raids/RaidCombobox/index.tsx
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
import { createRef, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
} 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'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showAllRaidsOption: boolean
|
||||||
|
currentRaid?: Raid
|
||||||
|
defaultRaid?: Raid
|
||||||
|
onChange?: (raid?: Raid) => void
|
||||||
|
onBlur?: (event: React.ChangeEvent<HTMLSelectElement>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
import Button from '~components/common/Button'
|
||||||
|
import ArrowIcon from '~public/icons/Arrow.svg'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import { appState } from '~utils/appState'
|
||||||
|
|
||||||
|
const NUM_SECTIONS = 3
|
||||||
|
|
||||||
|
enum Sort {
|
||||||
|
ASCENDING,
|
||||||
|
DESCENDING,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [search, setSearch] = useState('')
|
||||||
|
const [sections, setSections] = useState<RaidGroup[][]>()
|
||||||
|
const [currentRaid, setCurrentRaid] = useState<Raid>()
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const listRef = createRef<HTMLDivElement>()
|
||||||
|
const selectedRef = createRef<HTMLDivElement>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (appState.party.raid) {
|
||||||
|
setCurrentRaid(appState.party.raid)
|
||||||
|
setCurrentSection(appState.party.raid.group.section)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Scroll to the top of the list when the user switches tabs
|
||||||
|
useEffect(() => {
|
||||||
|
if (listRef.current) listRef.current.scrollTop = 0
|
||||||
|
}, [currentSection])
|
||||||
|
|
||||||
|
const scrollToItem = useCallback(
|
||||||
|
(node) => {
|
||||||
|
if (
|
||||||
|
!scrolled &&
|
||||||
|
open &&
|
||||||
|
currentRaid &&
|
||||||
|
listRef.current &&
|
||||||
|
node !== null
|
||||||
|
) {
|
||||||
|
const listRect = listRef.current.getBoundingClientRect()
|
||||||
|
const itemRect = node.getBoundingClientRect()
|
||||||
|
const distance = itemRect.top - listRect.top
|
||||||
|
|
||||||
|
listRef.current.scrollTop = distance
|
||||||
|
setScrolled(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[open, currentRaid, listRef]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Methods: Convenience methods
|
||||||
|
|
||||||
|
function reverseSort() {
|
||||||
|
if (sort === Sort.ASCENDING) setSort(Sort.DESCENDING)
|
||||||
|
else setSort(Sort.ASCENDING)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort raids into defined groups
|
||||||
|
const sortGroups = useCallback(
|
||||||
|
(groups: RaidGroup[]) => {
|
||||||
|
const sections: [RaidGroup[], RaidGroup[], RaidGroup[]] = [[], [], []]
|
||||||
|
|
||||||
|
groups.forEach((group) => {
|
||||||
|
if (group.section > 0) sections[group.section - 1].push(group)
|
||||||
|
})
|
||||||
|
|
||||||
|
setSections(sections)
|
||||||
|
},
|
||||||
|
[setSections]
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleValueChange(raid: Raid) {
|
||||||
|
setCurrentRaid(raid)
|
||||||
|
setOpen(false)
|
||||||
|
setScrolled(false)
|
||||||
|
if (props.onChange) props.onChange(raid)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOpen() {
|
||||||
|
if (open) {
|
||||||
|
if (currentRaid) setCurrentSection(currentRaid.group.section)
|
||||||
|
setScrolled(false)
|
||||||
|
}
|
||||||
|
setOpen(!open)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch all raids on mount
|
||||||
|
useEffect(() => {
|
||||||
|
api.raidGroups().then((response) => sortGroups(response.data))
|
||||||
|
}, [sortGroups])
|
||||||
|
|
||||||
|
// Methods: Rendering
|
||||||
|
function renderRaidSections() {
|
||||||
|
let sections = []
|
||||||
|
for (let i = 0; i < NUM_SECTIONS; i++) {
|
||||||
|
sections.push(renderRaidSection(i))
|
||||||
|
}
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRaidSection(section: number) {
|
||||||
|
if (!sections || !sections[section]) return
|
||||||
|
else {
|
||||||
|
const currentSection = sections[section]
|
||||||
|
return currentSection
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (sort === Sort.ASCENDING) return a.order - b.order
|
||||||
|
else return b.order - a.order
|
||||||
|
})
|
||||||
|
.map((group, i) => renderRaidGroup(section, i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render JSX for each raid option, sorted into optgroups
|
||||||
|
function renderRaidGroup(section: number, index: number) {
|
||||||
|
let options = []
|
||||||
|
|
||||||
|
if (!sections || !sections[section] || !sections[section][index]) return
|
||||||
|
else {
|
||||||
|
const group = sections[section][index]
|
||||||
|
|
||||||
|
options = group.raids
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.element > 0 && b.element > 0) return a.element - b.element
|
||||||
|
else if (a.name.en.includes('NM') && b.name.en.includes('NM'))
|
||||||
|
return a.level < b.level ? -1 : 1
|
||||||
|
else return a.name.en < b.name.en ? -1 : 1
|
||||||
|
})
|
||||||
|
.map((item, i) => renderRaidItem(item, i))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandGroup
|
||||||
|
data-section={group.section}
|
||||||
|
className={classNames({
|
||||||
|
CommandGroup: true,
|
||||||
|
Hidden: group.section !== currentSection,
|
||||||
|
})}
|
||||||
|
key={group.name[locale].toLowerCase().replace(' ', '-')}
|
||||||
|
heading={
|
||||||
|
<div className="Label">
|
||||||
|
{group.name[locale]}
|
||||||
|
<div className="Separator" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{options}
|
||||||
|
</CommandGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRaidItem(raid: Raid, key: number) {
|
||||||
|
return (
|
||||||
|
<RaidItem
|
||||||
|
className={currentRaid && currentRaid.id === raid.id ? 'Selected' : ''}
|
||||||
|
icon={{
|
||||||
|
alt: raid.name[locale],
|
||||||
|
src: `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/raids/${raid.slug}.png`,
|
||||||
|
}}
|
||||||
|
extra={raid.group.extra}
|
||||||
|
key={key}
|
||||||
|
selected={currentRaid?.id === raid.id}
|
||||||
|
ref={
|
||||||
|
currentRaid && currentRaid.id === raid.id ? scrollToItem : undefined
|
||||||
|
}
|
||||||
|
value={raid.slug}
|
||||||
|
onSelect={() => handleValueChange(raid)}
|
||||||
|
>
|
||||||
|
{raid.name[locale]}
|
||||||
|
</RaidItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
className="Flush"
|
||||||
|
open={open}
|
||||||
|
onOpenChange={toggleOpen}
|
||||||
|
placeholder={t('raids.placeholder')}
|
||||||
|
trigger={{ className: 'Raid' }}
|
||||||
|
value={
|
||||||
|
currentRaid
|
||||||
|
? {
|
||||||
|
element: (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
{currentRaid.group.extra ? (
|
||||||
|
<i className="ExtraIndicator">EX</i>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
rawValue: currentRaid?.id,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Command className="Raid Combobox">
|
||||||
|
<div className="Header">
|
||||||
|
<CommandInput
|
||||||
|
className="Input Bound"
|
||||||
|
placeholder={t('search.placeholders.raid')}
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
/>
|
||||||
|
{!search ? (
|
||||||
|
<div className="Controls">
|
||||||
|
<SegmentedControl blended={true}>
|
||||||
|
<Segment
|
||||||
|
groupName="raid_section"
|
||||||
|
name="events"
|
||||||
|
selected={currentSection === 2}
|
||||||
|
onClick={() => setCurrentSection(2)}
|
||||||
|
>
|
||||||
|
{t('raids.sections.events')}
|
||||||
|
</Segment>
|
||||||
|
<Segment
|
||||||
|
groupName="raid_section"
|
||||||
|
name="raids"
|
||||||
|
selected={currentSection === 1}
|
||||||
|
onClick={() => setCurrentSection(1)}
|
||||||
|
>
|
||||||
|
{t('raids.sections.raids')}
|
||||||
|
</Segment>
|
||||||
|
<Segment
|
||||||
|
groupName="raid_section"
|
||||||
|
name="solo"
|
||||||
|
selected={currentSection === 3}
|
||||||
|
onClick={() => setCurrentSection(3)}
|
||||||
|
>
|
||||||
|
{t('raids.sections.solo')}
|
||||||
|
</Segment>
|
||||||
|
</SegmentedControl>
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={classNames({ Raids: true, Searching: search !== '' })}
|
||||||
|
ref={listRef}
|
||||||
|
>
|
||||||
|
{renderRaidSections()}
|
||||||
|
</div>
|
||||||
|
</Command>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RaidCombobox
|
||||||
53
components/raids/RaidItem/index.scss
Normal file
53
components/raids/RaidItem/index.scss
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
.SelectItem.Raid {
|
||||||
|
padding-top: $unit;
|
||||||
|
padding-bottom: $unit;
|
||||||
|
padding-left: $unit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.ExtraIndicator {
|
||||||
|
background: var(--extra-purple-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Selected {
|
||||||
|
background-color: var(--pill-bg-hover);
|
||||||
|
color: var(--pill-text-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.Text {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExtraIndicator {
|
||||||
|
background: var(--extra-purple-bg);
|
||||||
|
border-radius: $full-corner;
|
||||||
|
color: var(--extra-purple-text);
|
||||||
|
display: flex;
|
||||||
|
font-weight: $bold;
|
||||||
|
font-size: $font-tiny;
|
||||||
|
width: $unit-3x;
|
||||||
|
height: $unit-3x;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Selected {
|
||||||
|
background-color: var(--pill-bg);
|
||||||
|
color: var(--pill-text);
|
||||||
|
border-radius: $full-corner;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: $font-tiny;
|
||||||
|
font-weight: $bold;
|
||||||
|
padding: 0 $unit;
|
||||||
|
height: $unit-3x;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
background: var(--input-bound-bg);
|
||||||
|
border-radius: $unit-half;
|
||||||
|
width: $unit-10x;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
components/raids/RaidItem/index.tsx
Normal file
57
components/raids/RaidItem/index.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import React, { ComponentProps, PropsWithChildren } from 'react'
|
||||||
|
import { CommandItem } from '~components/common/Command'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string
|
||||||
|
icon?: {
|
||||||
|
alt: string
|
||||||
|
src: string
|
||||||
|
}
|
||||||
|
extra: boolean
|
||||||
|
selected: boolean
|
||||||
|
value: string | number
|
||||||
|
onSelect: () => void
|
||||||
|
}
|
||||||
|
const RaidItem = React.forwardRef<HTMLDivElement, PropsWithChildren<Props>>(
|
||||||
|
function Item(
|
||||||
|
{
|
||||||
|
icon,
|
||||||
|
value,
|
||||||
|
extra,
|
||||||
|
selected,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<Props>,
|
||||||
|
forwardedRef
|
||||||
|
) {
|
||||||
|
const classes = classNames(
|
||||||
|
{ SelectItem: true, Raid: true },
|
||||||
|
props.className
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
{...props}
|
||||||
|
className={classes}
|
||||||
|
value={`${value}`}
|
||||||
|
onClick={props.onSelect}
|
||||||
|
ref={forwardedRef}
|
||||||
|
>
|
||||||
|
{icon ? <img alt={icon.alt} src={icon.src} /> : ''}
|
||||||
|
<span className="Text">{children}</span>
|
||||||
|
{selected ? <i className="Selected">Selected</i> : ''}
|
||||||
|
{extra ? <i className="ExtraIndicator">EX</i> : ''}
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
RaidItem.defaultProps = {
|
||||||
|
extra: false,
|
||||||
|
selected: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RaidItem
|
||||||
Loading…
Reference in a new issue