@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
149 lines • 6.19 kB
JavaScript
import { ObservableValue } from '@furystack/utils';
/**
* Service for managing list state including focus, selection, and keyboard navigation
*/
export class ListService {
options;
[Symbol.dispose]() {
this.items[Symbol.dispose]();
this.selection[Symbol.dispose]();
this.searchTerm[Symbol.dispose]();
this.hasFocus[Symbol.dispose]();
this.focusedItem[Symbol.dispose]();
}
isSelected = (item) => this.selection.getValue().includes(item);
addToSelection = (item) => {
this.selection.setValue([...this.selection.getValue(), item]);
};
removeFromSelection = (item) => {
this.selection.setValue(this.selection.getValue().filter((e) => e !== item));
};
toggleSelection = (item) => {
if (this.isSelected(item)) {
this.removeFromSelection(item);
}
else {
this.addToSelection(item);
}
};
items = new ObservableValue([]);
focusedItem = new ObservableValue(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.
*/
focusAnchor = undefined;
/** Snapshot the current focused item as the anchor for SHIFT+click range selection. */
setFocusAnchor() {
this.focusAnchor = this.focusedItem.getValue();
}
selection = new ObservableValue([]);
searchTerm = new ObservableValue('');
hasFocus = new ObservableValue(false);
handleKeyDown(ev) {
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]?.toString().startsWith(newSearchExpression));
this.focusedItem.setValue(newFocusedItem);
this.searchTerm.setValue(newSearchExpression);
}
}
}
}
handleItemClick(item, ev) {
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.
*/
handleItemDoubleClick(_item) { }
constructor(options = {}) {
this.options = options;
}
}
//# sourceMappingURL=list-service.js.map