UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

246 lines (219 loc) 8.11 kB
import { EventHub, ObservableValue, type ListenerErrorPayload } from '@furystack/utils' export interface CollectionData<T> { entries: T[] count: number } export interface CollectionServiceOptions<T> { /** * An optional field that can be used for quick search */ searchField?: keyof T /** * A field used as a stable identity key for entries. * When provided, the service automatically reconciles `focusedEntry`, * `selection`, and the internal SHIFT+click focus anchor after `data` * changes so that stale object references are swapped for their matching * counterparts in the new data array. This keeps keyboard navigation and * selection working correctly when the backing data is rebuilt with new * object instances. */ idField?: keyof T } export class CollectionService<T> extends EventHub<{ onRowClick: T onRowDoubleClick: T onListenerError: ListenerErrorPayload }> implements Disposable { private dataSubscription?: Disposable public [Symbol.dispose]() { this.dataSubscription?.[Symbol.dispose]() this.data[Symbol.dispose]() this.selection[Symbol.dispose]() this.searchTerm[Symbol.dispose]() this.hasFocus[Symbol.dispose]() this.focusedEntry[Symbol.dispose]() super[Symbol.dispose]() } public isSelected = (entry: T) => this.selection.getValue().includes(entry) public addToSelection = (entry: T) => { this.selection.setValue([...this.selection.getValue(), entry]) } public removeFromSelection = (entry: T) => { this.selection.setValue(this.selection.getValue().filter((e) => e !== entry)) } public toggleSelection = (entry: T) => { if (this.isSelected(entry)) { this.removeFromSelection(entry) } else { this.addToSelection(entry) } } public data = new ObservableValue<CollectionData<T>>({ count: 0, entries: [] }) public focusedEntry = new ObservableValue<T | undefined>(undefined) /** * Stores the focused entry captured on pointerdown, before the focus event * can update focusedEntry. Used as the anchor for SHIFT+click range selection. * Call {@link setFocusAnchor} from `onpointerdown` to snapshot the anchor * before focus shifts. */ private focusAnchor: T | undefined = undefined /** Snapshot the current focused entry as the anchor for SHIFT+click range selection. */ public setFocusAnchor(): void { this.focusAnchor = this.focusedEntry.getValue() } public selection = new ObservableValue<T[]>([]) public searchTerm = new ObservableValue('') public hasFocus = new ObservableValue(false) public handleKeyDown(ev: KeyboardEvent) { const { entries } = this.data.getValue() const hasFocus = this.hasFocus.getValue() const selectedEntries = this.selection.getValue() const focusedEntry = this.focusedEntry.getValue() const searchTerm = this.searchTerm.getValue() if (hasFocus) { switch (ev.key) { case ' ': ev.preventDefault() if (focusedEntry) { this.selection.setValue( selectedEntries.includes(focusedEntry) ? selectedEntries.filter((e) => e !== focusedEntry) : [...selectedEntries, focusedEntry], ) } break case '*': this.selection.setValue(entries.filter((e) => !selectedEntries.includes(e))) break case '+': this.selection.setValue(entries) break case '-': this.selection.setValue([]) break case 'Insert': if (focusedEntry) { if (this.selection.getValue().includes(focusedEntry)) { this.selection.setValue([...this.selection.getValue().filter((e) => e !== focusedEntry)]) } else { this.selection.setValue([...this.selection.getValue(), focusedEntry]) } this.focusedEntry.setValue(entries[entries.findIndex((e) => e === this.focusedEntry.getValue()) + 1]) } break case 'ArrowDown': { if (focusedEntry !== undefined) { const currentIndex = entries.indexOf(focusedEntry) if (currentIndex >= 0 && currentIndex < entries.length - 1) { ev.preventDefault() this.focusedEntry.setValue(entries[currentIndex + 1]) } } break } case 'ArrowUp': { if (focusedEntry !== undefined) { const currentIndex = entries.indexOf(focusedEntry) if (currentIndex > 0) { ev.preventDefault() this.focusedEntry.setValue(entries[currentIndex - 1]) } } break } case 'Home': { ev.preventDefault() this.focusedEntry.setValue(entries[0]) break } case 'End': { ev.preventDefault() this.focusedEntry.setValue(entries[entries.length - 1]) break } case 'Escape': { this.searchTerm.setValue('') this.selection.setValue([]) break } default: if (this.options.searchField && ev.key.length === 1) { const newSearchExpression = searchTerm + ev.key const newFocusedEntry = entries.find( (e) => this.options.searchField && (e[this.options.searchField] as string)?.toString().startsWith(newSearchExpression), ) this.focusedEntry.setValue(newFocusedEntry) this.searchTerm.setValue(newSearchExpression) } } } } public handleRowClick(entry: T, ev: MouseEvent) { this.emit('onRowClick', entry) const currentSelectionValue = this.selection.getValue() const lastFocused = this.focusAnchor ?? this.focusedEntry.getValue() this.focusAnchor = undefined if (ev.ctrlKey) { if (currentSelectionValue.includes(entry)) { this.selection.setValue(currentSelectionValue.filter((s) => s !== entry)) } else { this.selection.setValue([...currentSelectionValue, entry]) } } if (ev.shiftKey) { const lastFocusedIndex = this.data.getValue().entries.findIndex((e) => e === lastFocused) const entryIndex = this.data.getValue().entries.findIndex((e) => e === entry) const selection = [...currentSelectionValue] if (lastFocusedIndex > entryIndex) { for (let i = entryIndex; i <= lastFocusedIndex; i++) { selection.push(this.data.getValue().entries[i]) } } else { for (let i = lastFocusedIndex; i <= entryIndex; i++) { selection.push(this.data.getValue().entries[i]) } } this.selection.setValue(selection) } this.focusedEntry.setValue(entry) } private reconcileRefs(entries: T[]): void { const { idField } = this.options if (!idField) return const currentFocused = this.focusedEntry.getValue() if (currentFocused) { const reconciled = entries.find((e) => e[idField] === currentFocused[idField]) if (reconciled !== currentFocused) { this.focusedEntry.setValue(reconciled) } } if (this.focusAnchor) { const anchor = this.focusAnchor this.focusAnchor = entries.find((e) => e[idField] === anchor[idField]) } const currentSelection = this.selection.getValue() if (currentSelection.length > 0) { const entryById = new Map(entries.map((e) => [e[idField], e])) const reconciled = currentSelection.map((s) => entryById.get(s[idField])).filter((e): e is T => e !== undefined) if (reconciled.length !== currentSelection.length || reconciled.some((e, i) => e !== currentSelection[i])) { this.selection.setValue(reconciled) } } } constructor(private options: CollectionServiceOptions<T> = {}) { super() if (options.idField) { this.dataSubscription = this.data.subscribe(({ entries }) => { this.reconcileRefs(entries) }) } } public handleRowDoubleClick(entry: T) { this.emit('onRowDoubleClick', entry) } }