UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

172 lines (153 loc) 5.61 kB
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> = {}) {} }