Files
tolaria/src/components/note-list/ListPropertiesPopover.tsx
2026-04-12 22:29:36 +02:00

191 lines
6.5 KiB
TypeScript

import { useState, useMemo, useCallback, useEffect } from 'react'
import { SlidersHorizontal, DotsSixVertical } from '@phosphor-icons/react'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/utils'
import {
OPEN_NOTE_LIST_PROPERTIES_EVENT,
type NoteListPropertiesScope,
type OpenListPropertiesEventDetail,
} from './noteListPropertiesEvents'
import {
DndContext, closestCenter, PointerSensor,
useSensor, useSensors, type DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext, useSortable, verticalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
type NoteListPropertyKey = string
export interface ListPropertiesPopoverProps {
scope: NoteListPropertiesScope
availableProperties: NoteListPropertyKey[]
currentDisplay: NoteListPropertyKey[]
onSave: (value: NoteListPropertyKey[] | null) => void
triggerTitle: string
triggerClassName?: string
}
function propertyInputId(id: NoteListPropertyKey): string {
return `list-prop-${id.replace(/[^a-z0-9_-]+/gi, '-')}`
}
function getSelectedProperties(currentDisplay: NoteListPropertyKey[], availableProperties: NoteListPropertyKey[]) {
return currentDisplay.filter((property) => availableProperties.includes(property))
}
function getOrderedItems(currentDisplay: NoteListPropertyKey[], availableProperties: NoteListPropertyKey[]) {
const selected = getSelectedProperties(currentDisplay, availableProperties)
const unselected = availableProperties.filter((property) => !selected.includes(property))
return [...selected, ...unselected]
}
function toggleDisplayProperty(currentDisplay: NoteListPropertyKey[], selectedSet: Set<NoteListPropertyKey>, key: NoteListPropertyKey) {
if (selectedSet.has(key)) {
const filtered = currentDisplay.filter((property) => property !== key)
return filtered.length > 0 ? filtered : null
}
return [...currentDisplay, key]
}
function reorderDisplayProperties(event: DragEndEvent, currentDisplay: NoteListPropertyKey[], availableProperties: NoteListPropertyKey[]) {
const { active, over } = event
if (!over || active.id === over.id) return undefined
const selected = getSelectedProperties(currentDisplay, availableProperties)
const oldIndex = selected.indexOf(String(active.id) as NoteListPropertyKey)
const newIndex = selected.indexOf(String(over.id) as NoteListPropertyKey)
if (oldIndex === -1 || newIndex === -1) return undefined
return arrayMove(selected, oldIndex, newIndex)
}
function SortablePropertyItem({ id, checked, onToggle }: { id: NoteListPropertyKey; checked: boolean; onToggle: (key: NoteListPropertyKey) => void }) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id })
const style = { transform: CSS.Transform.toString(transform), transition }
const inputId = propertyInputId(id)
const dragAttributes = { ...attributes, tabIndex: -1 }
return (
<div
ref={setNodeRef}
style={style}
className="flex items-center gap-2 rounded px-1 py-1 hover:bg-muted"
data-testid={`list-prop-item-${id}`}
>
<Checkbox
id={inputId}
checked={checked}
onCheckedChange={() => onToggle(id)}
aria-label={id}
/>
<label
htmlFor={inputId}
className="flex flex-1 cursor-pointer items-center gap-2 text-[13px]"
onClick={(event) => {
event.preventDefault()
onToggle(id)
}}
>
<span className="truncate">{id}</span>
</label>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing"
aria-label={`Reorder ${id}`}
{...dragAttributes}
{...listeners}
>
<DotsSixVertical size={14} />
</Button>
</div>
)
}
export function ListPropertiesPopover({
scope,
availableProperties,
currentDisplay,
onSave,
triggerTitle,
triggerClassName,
}: ListPropertiesPopoverProps) {
const [open, setOpen] = useState(false)
const orderedItems = useMemo(
() => getOrderedItems(currentDisplay, availableProperties),
[availableProperties, currentDisplay],
)
const selectedSet = useMemo(() => new Set(currentDisplay), [currentDisplay])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
)
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<OpenListPropertiesEventDetail>).detail
if (detail?.scope === scope) setOpen(true)
}
window.addEventListener(OPEN_NOTE_LIST_PROPERTIES_EVENT, handler)
return () => window.removeEventListener(OPEN_NOTE_LIST_PROPERTIES_EVENT, handler)
}, [scope])
const handleToggle = useCallback((key: string) => {
const nextSelected = toggleDisplayProperty(currentDisplay, selectedSet, key)
onSave(nextSelected)
}, [currentDisplay, onSave, selectedSet])
const handleDragEnd = useCallback((event: DragEndEvent) => {
const reordered = reorderDisplayProperties(event, currentDisplay, availableProperties)
if (!reordered) return
onSave(reordered)
}, [availableProperties, currentDisplay, onSave])
if (availableProperties.length === 0) return null
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className={cn('h-7 w-7 text-muted-foreground', triggerClassName)}
title={triggerTitle}
aria-label={triggerTitle}
data-testid="list-properties-btn"
>
<SlidersHorizontal size={16} />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-2" data-testid="list-properties-popover">
<div className="mb-2 px-1 text-[11px] font-medium text-muted-foreground">
Show in note list
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={orderedItems} strategy={verticalListSortingStrategy}>
{orderedItems.map((key) => (
<SortablePropertyItem
key={key}
id={key}
checked={selectedSet.has(key)}
onToggle={handleToggle}
/>
))}
</SortableContext>
</DndContext>
</PopoverContent>
</Popover>
)
}