@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
246 lines (219 loc) • 8.11 kB
text/typescript
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)
}
}