@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
172 lines (153 loc) • 5.61 kB
text/typescript
import { ObservableValue } from '@furystack/utils'
export type ListServiceOptions<T> = {
/**
* An optional field that can be used for type-ahead search
*/
searchField?: keyof T
}
/**
* Service for managing list state including focus, selection, and keyboard navigation
*/
export class ListService<T> implements Disposable {
public [Symbol.dispose]() {
this.items[Symbol.dispose]()
this.selection[Symbol.dispose]()
this.searchTerm[Symbol.dispose]()
this.hasFocus[Symbol.dispose]()
this.focusedItem[Symbol.dispose]()
}
public isSelected = (item: T) => this.selection.getValue().includes(item)
public addToSelection = (item: T) => {
this.selection.setValue([...this.selection.getValue(), item])
}
public removeFromSelection = (item: T) => {
this.selection.setValue(this.selection.getValue().filter((e) => e !== item))
}
public toggleSelection = (item: T) => {
if (this.isSelected(item)) {
this.removeFromSelection(item)
} else {
this.addToSelection(item)
}
}
public items = new ObservableValue<T[]>([])
public focusedItem = new ObservableValue<T | undefined>(undefined)
/**
* Stores the focused item captured on pointerdown, before the focus event
* can update focusedItem. 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 item as the anchor for SHIFT+click range selection. */
public setFocusAnchor(): void {
this.focusAnchor = this.focusedItem.getValue()
}
public selection = new ObservableValue<T[]>([])
public searchTerm = new ObservableValue('')
public hasFocus = new ObservableValue(false)
public handleKeyDown(ev: KeyboardEvent) {
const items = this.items.getValue()
const hasFocus = this.hasFocus.getValue()
const selectedItems = this.selection.getValue()
const focusedItem = this.focusedItem.getValue()
const searchTerm = this.searchTerm.getValue()
if (hasFocus) {
switch (ev.key) {
case ' ':
ev.preventDefault()
if (focusedItem) {
this.selection.setValue(
selectedItems.includes(focusedItem)
? selectedItems.filter((e) => e !== focusedItem)
: [...selectedItems, focusedItem],
)
}
break
case '*':
ev.preventDefault()
this.selection.setValue(items.filter((e) => !selectedItems.includes(e)))
break
case '+':
ev.preventDefault()
this.selection.setValue(items)
break
case '-':
ev.preventDefault()
this.selection.setValue([])
break
case 'Insert':
ev.preventDefault()
if (focusedItem) {
if (this.selection.getValue().includes(focusedItem)) {
this.selection.setValue([...this.selection.getValue().filter((e) => e !== focusedItem)])
} else {
this.selection.setValue([...this.selection.getValue(), focusedItem])
}
this.focusedItem.setValue(items[items.findIndex((e) => e === this.focusedItem.getValue()) + 1])
}
break
case 'Home': {
ev.preventDefault()
this.focusedItem.setValue(items[0])
break
}
case 'End': {
ev.preventDefault()
this.focusedItem.setValue(items[items.length - 1])
break
}
case 'Escape': {
ev.preventDefault()
this.searchTerm.setValue('')
this.selection.setValue([])
break
}
default:
if (this.options.searchField && ev.key.length === 1) {
const newSearchExpression = searchTerm + ev.key
const newFocusedItem = items.find(
(e) =>
this.options.searchField &&
(e[this.options.searchField] as string)?.toString().startsWith(newSearchExpression),
)
this.focusedItem.setValue(newFocusedItem)
this.searchTerm.setValue(newSearchExpression)
}
}
}
}
public handleItemClick(item: T, ev: MouseEvent) {
const currentSelectionValue = this.selection.getValue()
const lastFocused = this.focusAnchor ?? this.focusedItem.getValue()
this.focusAnchor = undefined
if (ev.ctrlKey) {
if (currentSelectionValue.includes(item)) {
this.selection.setValue(currentSelectionValue.filter((s) => s !== item))
} else {
this.selection.setValue([...currentSelectionValue, item])
}
}
if (ev.shiftKey) {
const items = this.items.getValue()
const lastFocusedIndex = items.findIndex((e) => e === lastFocused)
const itemIndex = items.findIndex((e) => e === item)
const start = Math.min(lastFocusedIndex, itemIndex)
const end = Math.max(lastFocusedIndex, itemIndex)
const rangeItems = items.slice(start, end + 1)
const newSelection = [...currentSelectionValue]
for (const rangeItem of rangeItems) {
if (!newSelection.includes(rangeItem)) {
newSelection.push(rangeItem)
}
}
this.selection.setValue(newSelection)
}
this.focusedItem.setValue(item)
}
/**
* Hook for double-click behavior. No-op in base class; overridden by TreeService for expand/collapse.
*/
public handleItemDoubleClick(_item: T) {}
constructor(private options: ListServiceOptions<T> = {}) {}
}