/** * Usage in Grist: Multi-selector component serves as the base class for Sorting, Filtering, * Group-By, Linking, and possibly other widgets in Grist. * * The multi-selector component allows the user to create an ordered list of items selected from a * unique list. Visually it shows a list of Items, and a link to add a new item. Each item shows a * trash-can icon to remove it. Optionally, items may be reordered relative to each other using a * dragger. Each Item contains a dropdown which shows a fixed set of options (e.g. column names). * Options already selected (present as other Items) are omitted from the list. * * Using the link to add a new item creates an "Empty Item" row, which then gets added to * the list and becomes a real item when the user chooses a value. Note that the Empty Item * uses the empty string '' as its value, so it is not a valid value in the list of items. * * The MultiSelect class may be extended to be used with enhanced items, to show additional UI (to * control additional properties of items, e.g. ascending/descending for sorting), and to provide * custom implementation for changes (e.g. adding an item may involve a request to the server). * * TODO: Implement optional reordering of items * TODO: Optionally omit selected items from list */ import { computed, MutableObsArray, ObsArray, observable, Observable } from 'grainjs'; import { Disposable, dom, makeTestId, select, styled } from 'grainjs'; import { button1 } from './buttons'; export interface BaseItem { value: any; label: string; } const testId = makeTestId('test-ms-'); export abstract class MultiItemSelector extends Disposable { constructor(private _incItems: MutableObsArray, private _allItems: ObsArray, private _options: { addItemLabel: string, addItemText: string }) { super(); } public buildDom() { return cssMultiSelectorWrapper( cssItemList(testId('list'), dom.forEach(this._incItems, item => this.buildItemDom(item)), this._buildAddItemDom(this._options.addItemLabel, this._options.addItemText) ), ); } // Must be overridden to return list of available items. Items already present in the items // array (according to value) may be safely included, and will not be shown in the select-box. // The default implementations update items array, but may be overridden. protected async add(item: Item): Promise { this._incItems.push(item); } // Called with an item from `_allItems` protected async remove(item: Item): Promise { const idx = this._findIncIndex(item); if (idx === -1) { return; } this._incItems.splice(idx, 1); } // TODO: Called with an item in the items array protected async reorder(item: Item, nextItem: Item): Promise { return; } // Replaces an existing item (if found) with a new one protected async changeItem(item: Item, newItem: Item): Promise { const idx = this._findIncIndex(item); if (idx === -1) { return; } this._incItems.splice(idx, 1, newItem); } // Exposed for use by custom buildItemDom(). protected buildDragHandle(item: Item): Element { return new Element(); } protected buildSelectBox(selectedValue: string, selectCb: (newItem: Item) => void, selectOptions?: {}): Element { const obs = computed(use => selectedValue).onWrite(async value => { const newItem = this._findItemByValue(value); if (newItem) { selectCb(newItem); } }); const result = select( obs, this._allItems, selectOptions ); dom.autoDisposeElem(result, obs); return result; } protected buildRemoveButton(removeCb: () => void): Element { return cssItemRemove(testId('remove-btn'), dom.on('click', removeCb), '✖' ); } // May be overridden for custom-looking items. protected buildItemDom(item: Item): Element { return dom('li', testId('item'), // this.buildDragHandle(item), TODO: once dragging is implemented this.buildSelectBox(item.value, async newItem => this.changeItem(item, newItem)), this.buildRemoveButton(() => this.remove(item)) ); } // Returns the index (order) of the item if it's been included, or -1 otherwise. private _findIncIndex(item: Item): number { return this._incItems.get().findIndex(_item => _item === item); } // Returns the item object given it's value, or undefined if not found. private _findItemByValue(value: string): Item | undefined { return this._allItems.get().find(_item => _item.value === value); } // Builds the about-to-be-added item private _buildAddItemDom(defLabel: string, defText: string): Element { const addNewItem: Observable = observable(false); return dom('li', testId('add-item'), dom.domComputed(addNewItem, isAdding => isAdding ? dom.frag( this.buildSelectBox('', async newItem => { await this.add(newItem); addNewItem.set(false); }, { defLabel }), this.buildRemoveButton(() => addNewItem.set(false))) : button1(defText, testId('add-btn'), dom.on('click', () => addNewItem.set(true))) ) ); } } const cssMultiSelectorWrapper = styled('div', ` border: 1px solid blue; `); const cssItemList = styled('ul', ` list-style-type: none; `); const cssItemRemove = styled('span', ` padding: 0 .5rem; vertical-align: middle; cursor: pointer; `);