2025-06-20 05:41:10 +00:00
|
|
|
import {
|
|
|
|
|
autoUpdate,
|
|
|
|
|
FloatingFocusManager,
|
|
|
|
|
FloatingPortal,
|
|
|
|
|
flip,
|
|
|
|
|
size,
|
|
|
|
|
useDismiss,
|
|
|
|
|
useFloating,
|
|
|
|
|
useInteractions,
|
|
|
|
|
useListNavigation,
|
|
|
|
|
useRole,
|
|
|
|
|
} from '@floating-ui/react'
|
|
|
|
|
import Fuse from 'fuse.js'
|
|
|
|
|
import { useAtom, useAtomValue } from 'jotai'
|
|
|
|
|
import { forwardRef, useId, useMemo, useRef, useState } from 'react'
|
2025-06-23 06:33:03 +00:00
|
|
|
import { charactersAtom, selectedCharacterAtom, selectedTargetInventoryAtom } from '@/state/atoms'
|
2024-08-12 01:13:42 +00:00
|
|
|
|
2025-05-25 05:17:41 +00:00
|
|
|
interface AccountInventorySelectorItemProps {
|
2025-06-20 05:41:10 +00:00
|
|
|
children: React.ReactNode
|
|
|
|
|
active: boolean
|
2025-05-13 21:02:59 +00:00
|
|
|
}
|
|
|
|
|
|
2025-05-25 05:17:41 +00:00
|
|
|
const AccountInventorySelectorItem = forwardRef<
|
2025-05-13 21:02:59 +00:00
|
|
|
HTMLDivElement,
|
2025-05-25 05:17:41 +00:00
|
|
|
AccountInventorySelectorItemProps & React.HTMLProps<HTMLDivElement>
|
2025-05-13 21:02:59 +00:00
|
|
|
>(({ children, active, ...rest }, ref) => {
|
2025-06-20 05:41:10 +00:00
|
|
|
const id = useId()
|
2025-06-23 06:33:03 +00:00
|
|
|
const isDisabled = rest['aria-disabled']
|
2025-05-13 21:02:59 +00:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
ref={ref}
|
2025-06-20 06:18:37 +00:00
|
|
|
// biome-ignore lint/a11y/useSemanticElements: Custom autocomplete component needs role="option"
|
2025-05-13 21:02:59 +00:00
|
|
|
role="option"
|
|
|
|
|
id={id}
|
|
|
|
|
aria-selected={active}
|
2025-06-23 06:33:03 +00:00
|
|
|
aria-disabled={isDisabled}
|
2025-06-20 06:18:37 +00:00
|
|
|
tabIndex={-1}
|
2025-05-13 21:02:59 +00:00
|
|
|
{...rest}
|
|
|
|
|
style={{
|
2025-06-23 06:33:03 +00:00
|
|
|
background: active && !isDisabled ? 'lightblue' : 'none',
|
2025-05-13 21:02:59 +00:00
|
|
|
padding: 4,
|
2025-06-23 06:33:03 +00:00
|
|
|
cursor: isDisabled ? 'not-allowed' : 'default',
|
|
|
|
|
opacity: isDisabled ? 0.5 : 1,
|
2025-05-13 21:02:59 +00:00
|
|
|
...rest.style,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
2025-06-20 05:41:10 +00:00
|
|
|
)
|
|
|
|
|
})
|
2025-05-25 05:17:41 +00:00
|
|
|
export const InventoryTargetSelector = () => {
|
2025-06-20 05:41:10 +00:00
|
|
|
const [open, setOpen] = useState(false)
|
|
|
|
|
const [inputValue, setInputValue] = useState('')
|
|
|
|
|
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
2025-05-13 21:02:59 +00:00
|
|
|
|
2025-06-20 05:41:10 +00:00
|
|
|
const listRef = useRef<Array<HTMLElement | null>>([])
|
2025-05-13 21:02:59 +00:00
|
|
|
|
|
|
|
|
const { refs, floatingStyles, context } = useFloating<HTMLInputElement>({
|
|
|
|
|
whileElementsMounted: autoUpdate,
|
|
|
|
|
open,
|
|
|
|
|
onOpenChange: setOpen,
|
|
|
|
|
middleware: [
|
|
|
|
|
flip({ padding: 10 }),
|
|
|
|
|
size({
|
|
|
|
|
apply({ rects, availableHeight, elements }) {
|
|
|
|
|
Object.assign(elements.floating.style, {
|
2025-06-23 06:33:03 +00:00
|
|
|
width: `${Math.max(rects.reference.width * 2, 400)}px`,
|
2025-05-13 21:02:59 +00:00
|
|
|
maxHeight: `${availableHeight}px`,
|
2025-06-20 05:41:10 +00:00
|
|
|
})
|
2025-05-13 21:02:59 +00:00
|
|
|
},
|
|
|
|
|
padding: 10,
|
|
|
|
|
}),
|
|
|
|
|
],
|
2025-06-20 05:41:10 +00:00
|
|
|
})
|
2025-05-13 21:02:59 +00:00
|
|
|
|
2025-06-20 05:41:10 +00:00
|
|
|
const role = useRole(context, { role: 'listbox' })
|
|
|
|
|
const dismiss = useDismiss(context)
|
2025-05-13 21:02:59 +00:00
|
|
|
const listNav = useListNavigation(context, {
|
|
|
|
|
listRef,
|
|
|
|
|
activeIndex,
|
|
|
|
|
onNavigate: setActiveIndex,
|
|
|
|
|
virtual: true,
|
|
|
|
|
loop: true,
|
2025-06-20 05:41:10 +00:00
|
|
|
})
|
2025-05-13 21:02:59 +00:00
|
|
|
|
2025-06-20 05:41:10 +00:00
|
|
|
const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
|
|
|
|
|
role,
|
|
|
|
|
dismiss,
|
|
|
|
|
listNav,
|
|
|
|
|
])
|
2025-05-13 21:02:59 +00:00
|
|
|
|
|
|
|
|
function onChange(event: React.ChangeEvent<HTMLInputElement>) {
|
2025-06-20 05:41:10 +00:00
|
|
|
const value = event.target.value
|
|
|
|
|
setInputValue(value)
|
2025-05-13 21:02:59 +00:00
|
|
|
setSelectedTargetInventory(undefined)
|
|
|
|
|
|
|
|
|
|
if (value) {
|
2025-06-20 05:41:10 +00:00
|
|
|
setOpen(true)
|
|
|
|
|
setActiveIndex(0)
|
2025-05-13 21:02:59 +00:00
|
|
|
} else {
|
2025-06-20 05:41:10 +00:00
|
|
|
setOpen(false)
|
2025-05-13 21:02:59 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const { data: subaccounts } = useAtomValue(charactersAtom)
|
2025-06-23 06:33:03 +00:00
|
|
|
const selectedCharacter = useAtomValue(selectedCharacterAtom)
|
2025-05-13 21:02:59 +00:00
|
|
|
|
|
|
|
|
const [selectedTargetInventory, setSelectedTargetInventory] = useAtom(selectedTargetInventoryAtom)
|
|
|
|
|
|
2025-06-20 05:41:10 +00:00
|
|
|
const searcher = useMemo(() => {
|
2025-06-23 06:33:03 +00:00
|
|
|
const allInventories = subaccounts?.flatMap(x => [x.bank, x.character]) || []
|
|
|
|
|
// Don't filter out current character, we'll disable it in the UI
|
|
|
|
|
return new Fuse(allInventories, {
|
2025-06-20 05:41:10 +00:00
|
|
|
keys: ['path', 'name'],
|
|
|
|
|
findAllMatches: true,
|
|
|
|
|
threshold: 0.8,
|
|
|
|
|
useExtendedSearch: true,
|
|
|
|
|
})
|
2025-05-13 21:02:59 +00:00
|
|
|
}, [subaccounts])
|
|
|
|
|
|
2025-06-23 06:33:03 +00:00
|
|
|
const items = inputValue
|
|
|
|
|
? searcher.search(inputValue, { limit: 10 }).map(x => x.item)
|
|
|
|
|
: subaccounts?.flatMap(x => [x.bank, x.character]).slice(0, 10) || []
|
2025-05-13 21:02:59 +00:00
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<input
|
2025-06-23 06:33:03 +00:00
|
|
|
className={`border border-black-1 placeholder-gray-600 ${
|
|
|
|
|
selectedTargetInventory ? 'bg-green-100' : inputValue ? 'bg-yellow-200' : 'bg-gray-300'
|
|
|
|
|
}`}
|
2025-05-13 21:02:59 +00:00
|
|
|
{...getReferenceProps({
|
|
|
|
|
ref: refs.setReference,
|
|
|
|
|
onChange,
|
2025-06-23 06:33:03 +00:00
|
|
|
value:
|
|
|
|
|
selectedTargetInventory !== undefined
|
|
|
|
|
? !selectedTargetInventory.path.includes('/')
|
|
|
|
|
? `[Bank] ${selectedTargetInventory.account_name}`
|
|
|
|
|
: selectedTargetInventory.name
|
|
|
|
|
: inputValue,
|
2025-06-20 05:41:10 +00:00
|
|
|
placeholder: 'Target Inventory',
|
|
|
|
|
'aria-autocomplete': 'list',
|
2025-05-25 05:17:41 +00:00
|
|
|
onFocus() {
|
2025-06-20 05:41:10 +00:00
|
|
|
setOpen(true)
|
2025-05-25 05:17:41 +00:00
|
|
|
},
|
2025-05-13 21:02:59 +00:00
|
|
|
onKeyDown(event) {
|
2025-06-20 05:41:10 +00:00
|
|
|
if (event.key === 'Enter' && activeIndex != null && items[activeIndex]) {
|
2025-05-13 21:02:59 +00:00
|
|
|
setSelectedTargetInventory(items[activeIndex])
|
2025-06-23 06:33:03 +00:00
|
|
|
setInputValue('')
|
2025-06-20 05:41:10 +00:00
|
|
|
setActiveIndex(null)
|
|
|
|
|
setOpen(false)
|
2025-05-13 21:02:59 +00:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
})}
|
|
|
|
|
/>
|
|
|
|
|
{open && (
|
|
|
|
|
<FloatingPortal>
|
2025-06-20 05:41:10 +00:00
|
|
|
<FloatingFocusManager context={context} initialFocus={-1} visuallyHiddenDismiss>
|
2025-05-13 21:02:59 +00:00
|
|
|
<div
|
|
|
|
|
{...getFloatingProps({
|
|
|
|
|
ref: refs.setFloating,
|
|
|
|
|
style: {
|
|
|
|
|
...floatingStyles,
|
2025-06-20 05:41:10 +00:00
|
|
|
background: '#eee',
|
|
|
|
|
color: 'black',
|
|
|
|
|
overflowY: 'auto',
|
2025-05-13 21:02:59 +00:00
|
|
|
},
|
|
|
|
|
})}
|
|
|
|
|
>
|
2025-06-23 06:33:03 +00:00
|
|
|
<div style={{ display: 'flex', flexDirection: 'row', gap: '10px', padding: '5px' }}>
|
|
|
|
|
<div style={{ flex: 1 }}>
|
|
|
|
|
{items
|
|
|
|
|
.filter(item => item.path.includes('/'))
|
|
|
|
|
.map(item => {
|
|
|
|
|
const actualIndex = items.indexOf(item)
|
|
|
|
|
const isDisabled = item.path === selectedCharacter?.path
|
|
|
|
|
return (
|
|
|
|
|
<AccountInventorySelectorItem
|
|
|
|
|
key={item.path}
|
|
|
|
|
{...getItemProps({
|
|
|
|
|
ref(node) {
|
|
|
|
|
listRef.current[actualIndex] = node
|
|
|
|
|
},
|
|
|
|
|
onClick() {
|
|
|
|
|
if (!isDisabled) {
|
|
|
|
|
setInputValue('')
|
|
|
|
|
setSelectedTargetInventory(item)
|
|
|
|
|
setOpen(false)
|
|
|
|
|
refs.domReference.current?.focus()
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
})}
|
|
|
|
|
active={activeIndex === actualIndex}
|
|
|
|
|
aria-disabled={isDisabled}
|
|
|
|
|
>
|
|
|
|
|
{item.name}
|
|
|
|
|
</AccountInventorySelectorItem>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ flex: 1 }}>
|
|
|
|
|
{items
|
|
|
|
|
.filter(item => !item.path.includes('/'))
|
|
|
|
|
.map(item => {
|
|
|
|
|
const actualIndex = items.indexOf(item)
|
|
|
|
|
const isDisabled = item.path === selectedCharacter?.path
|
|
|
|
|
return (
|
|
|
|
|
<AccountInventorySelectorItem
|
|
|
|
|
key={item.path}
|
|
|
|
|
{...getItemProps({
|
|
|
|
|
ref(node) {
|
|
|
|
|
listRef.current[actualIndex] = node
|
|
|
|
|
},
|
|
|
|
|
onClick() {
|
|
|
|
|
if (!isDisabled) {
|
|
|
|
|
setInputValue('')
|
|
|
|
|
setSelectedTargetInventory(item)
|
|
|
|
|
setOpen(false)
|
|
|
|
|
refs.domReference.current?.focus()
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
})}
|
|
|
|
|
active={activeIndex === actualIndex}
|
|
|
|
|
aria-disabled={isDisabled}
|
|
|
|
|
>
|
|
|
|
|
[Bank] {item.account_name}
|
|
|
|
|
</AccountInventorySelectorItem>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-05-13 21:02:59 +00:00
|
|
|
</div>
|
|
|
|
|
</FloatingFocusManager>
|
|
|
|
|
</FloatingPortal>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
2025-06-20 05:41:10 +00:00
|
|
|
)
|
2025-05-13 21:02:59 +00:00
|
|
|
}
|