gristlabs_grist-core/app/client/widgets/ChoiceListEditor.ts
George Gevoian 5b2666a88a (core) Enhance autocomplete and choice colors
Summary:
Choice columns can now add new choices directly
from the autocomplete menu. The autocomplete will
now highlight the first matching item, even if there are equally
ranked alternatives. No changes have been made to how the
autocomplete index is created, or how it scores items.

For choice and choice list columns, the filter menu will
now display values using their configured colors, similar to the
rest of the UI. Choice tokens throughout the UI now do a better
job of handling text overflow by showing an ellipsis whenever
there isn't enough space to show the full text of a choice.

Test Plan: Browser tests.

Reviewers: cyprien

Reviewed By: cyprien

Differential Revision: https://phab.getgrist.com/D2904
2021-07-16 09:10:51 -07:00

365 lines
12 KiB
TypeScript

import {createGroup} from 'app/client/components/commands';
import {ACIndexImpl, ACItem, ACResults, buildHighlightedDom, HighlightFunc} from 'app/client/lib/ACIndex';
import {IAutocompleteOptions} from 'app/client/lib/autocomplete';
import {IToken, TokenField, tokenFieldStyles} from 'app/client/lib/TokenField';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {menuCssClass} from 'app/client/ui2018/menus';
import {cssInvalidToken} from 'app/client/widgets/ChoiceListCell';
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
import {EditorPlacement} from 'app/client/widgets/EditorPlacement';
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
import {csvEncodeRow} from 'app/common/csvFormat';
import {CellValue} from "app/common/DocActions";
import {decodeObject, encodeObject} from 'app/plugin/objtypes';
import {dom, styled} from 'grainjs';
import {ChoiceOptions, getFillColor, getTextColor} from 'app/client/widgets/ChoiceTextBox';
import {choiceToken, cssChoiceACItem} from 'app/client/widgets/ChoiceToken';
import {icon} from 'app/client/ui2018/icons';
export class ChoiceItem implements ACItem, IToken {
public cleanText: string = this.label.toLowerCase().trim();
constructor(
public label: string,
public isInvalid: boolean, // If set, this token is not one of the valid choices.
public isNew?: boolean, // If set, this is a choice to be added to the config.
) {}
}
export class ChoiceListEditor extends NewBaseEditor {
protected cellEditorDiv: HTMLElement;
protected commandGroup: any;
private _tokenField: TokenField<ChoiceItem>;
private _textInput: HTMLInputElement;
private _dom: HTMLElement;
private _editorPlacement: EditorPlacement;
private _contentSizer: HTMLElement; // Invisible element to size the editor with all the tokens
private _inputSizer: HTMLElement; // Part of _contentSizer to size the text input
private _alignment: string;
// Whether to include a button to show a new choice.
// TODO: Disable when the user cannot change column configuration.
private _enableAddNew: boolean = true;
private _showAddNew: boolean = false;
private _choiceOptionsByName: ChoiceOptions;
constructor(options: Options) {
super(options);
const choices: string[] = options.field.widgetOptionsJson.peek().choices || [];
this._choiceOptionsByName = options.field.widgetOptionsJson
.peek().choiceOptions || {};
const acItems = choices.map(c => new ChoiceItem(c, false));
const choiceSet = new Set(choices);
const acIndex = new ACIndexImpl<ChoiceItem>(acItems);
const acOptions: IAutocompleteOptions<ChoiceItem> = {
menuCssClass: `${menuCssClass} ${cssChoiceList.className} test-autocomplete`,
search: async (term: string) => this._maybeShowAddNew(acIndex.search(term), term),
renderItem: (item, highlightFunc) => this._renderACItem(item, highlightFunc),
getItemText: (item) => item.label,
};
this.commandGroup = this.autoDispose(createGroup(options.commands, null, true));
this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left';
// If starting to edit by typing in a string, ignore previous tokens.
const cellValue = decodeObject(options.cellValue);
const startLabels: unknown[] = options.editValue || !Array.isArray(cellValue) ? [] : cellValue;
const startTokens = startLabels.map(label => new ChoiceItem(String(label), !choiceSet.has(String(label))));
this._tokenField = TokenField.ctor<ChoiceItem>().create(this, {
initialValue: startTokens,
renderToken: item => [
item.label,
dom.style('background-color', getFillColor(this._choiceOptionsByName[item.label])),
dom.style('color', getTextColor(this._choiceOptionsByName[item.label])),
cssInvalidToken.cls('-invalid', item.isInvalid)
],
createToken: label => new ChoiceItem(label, !choiceSet.has(label)),
acOptions,
openAutocompleteOnFocus: true,
readonly : options.readonly,
trimLabels: true,
styles: {cssTokenField, cssToken, cssDeleteButton, cssDeleteIcon},
});
this._dom = dom('div.default_editor',
dom.cls("readonly_editor", options.readonly),
dom.cls(cssReadonlyStyle.className, options.readonly),
this.cellEditorDiv = cssCellEditor(testId('widget-text-editor'),
this._contentSizer = cssContentSizer(),
elem => this._tokenField.attach(elem),
),
createMobileButtons(options.commands),
);
this._textInput = this._tokenField.getTextInput();
dom.update(this._tokenField.getRootElem(),
dom.style('justify-content', this._alignment),
);
dom.update(this._tokenField.getHiddenInput(),
this.commandGroup.attach(),
);
dom.update(this._textInput,
// Resize the editor whenever user types into the textbox.
dom.on('input', () => this.resizeInput(true)),
dom.prop('value', options.editValue || ''),
this.commandGroup.attach(),
);
}
public attach(cellElem: Element): void {
// Attach the editor dom to page DOM.
this._editorPlacement = EditorPlacement.create(this, this._dom, cellElem, {margins: getButtonMargins()});
// Reposition the editor if needed for external reasons (in practice, window resize).
this.autoDispose(this._editorPlacement.onReposition.addListener(() => this.resizeInput()));
// Update the sizing whenever the tokens change. Delay it till next tick to give a chance for
// DOM updates that happen around tokenObs changes, to complete.
this.autoDispose(this._tokenField.tokensObs.addListener(() =>
Promise.resolve().then(() => this.resizeInput())));
this.setSizerLimits();
// Once the editor is attached to DOM, resize it to content, focus, and set cursor.
this.resizeInput();
this._textInput.focus();
const pos = Math.min(this.options.cursorPos, this._textInput.value.length);
this._textInput.setSelectionRange(pos, pos);
}
public getDom(): HTMLElement {
return this._dom;
}
public getCellValue(): CellValue {
return encodeObject(this._tokenField.tokensObs.get().map(item => item.label));
}
public getTextValue() {
const values = this._tokenField.tokensObs.get().map(t => t.label);
return csvEncodeRow(values, {prettier: true});
}
public getCursorPos(): number {
return this._textInput.selectionStart || 0;
}
/**
* Updates list of valid choices with any new ones that may have been
* added from directly inside the editor (via the "add new" item in autocomplete).
*/
public async prepForSave() {
const tokens = this._tokenField.tokensObs.get();
const newChoices = tokens.filter(t => t.isNew).map(t => t.label);
if (newChoices.length > 0) {
const choices = this.options.field.widgetOptionsJson.prop('choices');
await choices.saveOnly([...choices.peek(), ...new Set(newChoices)]);
}
}
public setSizerLimits() {
// Set the max width of the sizer to the max we could possibly grow to, so that it knows to wrap
// once we reach it.
const rootElem = this._tokenField.getRootElem();
const maxSize = this._editorPlacement.calcSizeWithPadding(rootElem,
{width: Infinity, height: Infinity}, {calcOnly: true});
this._contentSizer.style.maxWidth = Math.ceil(maxSize.width) + 'px';
}
/**
* Helper which resizes the token-field to match its content.
*/
protected resizeInput(onlyTextInput: boolean = false) {
if (this.isDisposed()) { return; }
const rootElem = this._tokenField.getRootElem();
// To size the content, we need both the tokens and the text typed into _textInput. We
// re-create the tokens using cloneNode(true) copies all styles and properties, but not event
// handlers. We can skip this step when we know that only _textInput changed.
if (!onlyTextInput || !this._inputSizer) {
this._contentSizer.innerHTML = '';
dom.update(this._contentSizer,
dom.update(rootElem.cloneNode(true) as HTMLElement,
dom.style('width', ''),
dom.style('height', ''),
this._inputSizer = cssInputSizer(),
// Remove the testId('tokenfield') from the cloned element, to simplify tests (so that
// selecting .test-tokenfield only returns the actual visible tokenfield container).
dom.cls('test-tokenfield', false),
)
);
}
// Use a separate sizer to size _textInput to the text inside it.
// \u200B is a zero-width space; so the sizer will have height even when empty.
this._inputSizer.textContent = this._textInput.value + '\u200B';
const rect = this._contentSizer.getBoundingClientRect();
const size = this._editorPlacement.calcSizeWithPadding(rootElem, rect);
rootElem.style.width = size.width + 'px';
rootElem.style.height = size.height + 'px';
this._textInput.style.width = this._inputSizer.getBoundingClientRect().width + 'px';
}
/**
* If the search text does not match anything exactly, adds 'new' item to it.
*
* Also see: prepForSave.
*/
private _maybeShowAddNew(result: ACResults<ChoiceItem>, text: string): ACResults<ChoiceItem> {
this._showAddNew = false;
const trimmedText = text.trim();
if (!this._enableAddNew || !trimmedText) { return result; }
const addNewItem = new ChoiceItem(trimmedText, false, true);
if (result.items.find((item) => item.cleanText === addNewItem.cleanText)) {
return result;
}
result.items.push(addNewItem);
this._showAddNew = true;
return result;
}
private _renderACItem(item: ChoiceItem, highlightFunc: HighlightFunc) {
const options = this._choiceOptionsByName[item.label];
return cssChoiceACItem(
(item.isNew ?
[cssChoiceACItem.cls('-new'), cssPlusButton(cssPlusIcon('Plus'))] :
[cssChoiceACItem.cls('-with-new', this._showAddNew)]
),
choiceToken(
buildHighlightedDom(item.label, highlightFunc, cssMatchText),
options || {},
dom.style('max-width', '100%'),
testId('choice-list-editor-item-label')
),
testId('choice-list-editor-item'),
item.isNew ? testId('choice-list-editor-new-item') : null,
);
}
}
const cssCellEditor = styled('div', `
background-color: white;
font-family: var(--grist-font-family-data);
font-size: var(--grist-medium-font-size);
`);
const cssTokenField = styled(tokenFieldStyles.cssTokenField, `
border: none;
align-items: start;
align-content: start;
padding: 0 3px;
height: min-content;
min-height: 22px;
color: black;
flex-wrap: wrap;
`);
const cssToken = styled(tokenFieldStyles.cssToken, `
padding: 1px 4px;
margin: 2px;
line-height: 16px;
white-space: pre;
&.selected {
box-shadow: inset 0 0 0 1px ${colors.lightGreen};
}
`);
const cssDeleteButton = styled(tokenFieldStyles.cssDeleteButton, `
position: absolute;
top: -8px;
right: -6px;
border-radius: 16px;
background-color: ${colors.dark};
width: 14px;
height: 14px;
cursor: pointer;
z-index: 1;
display: none;
align-items: center;
justify-content: center;
.${cssToken.className}:hover & {
display: flex;
}
.${cssTokenField.className}.token-dragactive & {
cursor: unset;
}
`);
const cssDeleteIcon = styled(tokenFieldStyles.cssDeleteIcon, `
--icon-color: ${colors.light};
&:hover {
--icon-color: ${colors.darkGrey};
}
`);
const cssContentSizer = styled('div', `
position: absolute;
left: 0;
top: -100px;
border: none;
visibility: hidden;
overflow: visible;
width: max-content;
& .${tokenFieldStyles.cssInputWrapper.className} {
display: none;
}
`);
const cssInputSizer = styled('div', `
flex: auto;
min-width: 24px;
margin: 3px 2px;
`);
// Set z-index to be higher than the 1000 set for .cell_editor.
export const cssChoiceList = styled('div', `
z-index: 1001;
box-shadow: 0 0px 8px 0 rgba(38,38,51,0.6);
overflow-y: auto;
padding: 8px 0 0 0;
--weaseljs-menu-item-padding: 8px 16px;
`);
const cssReadonlyStyle = styled('div', `
padding-left: 16px;
background: white;
`);
export const cssMatchText = styled('span', `
text-decoration: underline;
`);
export const cssPlusButton = styled('div', `
display: inline-block;
width: 20px;
height: 20px;
border-radius: 20px;
margin-right: 8px;
text-align: center;
background-color: ${colors.lightGreen};
color: ${colors.light};
.selected > & {
background-color: ${colors.darkGreen};
}
`);
export const cssPlusIcon = styled(icon, `
background-color: ${colors.light};
`);