lifeto-shop/src/components/inventory/movetarget.tsx

236 lines
7.7 KiB
TypeScript
Raw Normal View History

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
}