(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
This commit is contained in:
George Gevoian 2021-07-15 08:50:28 -07:00
parent 997be24a21
commit 5b2666a88a
10 changed files with 350 additions and 178 deletions

View File

@ -132,12 +132,10 @@ export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
const highlightFunc = highlightMatches.bind(null, searchWords);
// The best match is the first item. If it actually starts with the search text, AND has a
// strictly better score than other items, highlight it as a default selection. Otherwise, no
// item will be auto-selected.
// The best match is the first item. If any word in the item actually starts with the search
// text, highlight it as a default selection. Otherwise, no item will be auto-selected.
let selectIndex = -1;
if (items.length > 0 && items[0].cleanText.startsWith(cleanedSearchText) &&
(sortedMatches.length <= 1 || sortedMatches[1][1] < sortedMatches[0][1])) {
if (items.length > 0 && sortedMatches.length > 0 && startsWithText(items[0], cleanedSearchText)) {
selectIndex = 0;
}
return {items, highlightFunc, selectIndex};
@ -248,3 +246,13 @@ function findCommonPrefixLength(text1: string, text2: string): number {
while (i < text1.length && text1[i] === text2[i]) { ++i; }
return i;
}
/**
* Checks whether `item` starts with `text`, or has any words that start with `text`.
*/
function startsWithText(item: ACItem, text: string): boolean {
if (item.cleanText.startsWith(text)) { return true; }
const words = item.cleanText.split(wordSepRegexp);
return words.some(w => w.startsWith(text));
}

View File

@ -36,6 +36,7 @@ export interface ITokenFieldOptions<Token extends IToken> {
openAutocompleteOnFocus?: boolean;
styles?: ITokenFieldStyles;
readonly?: boolean;
trimLabels?: boolean;
keyBindings?: ITokenFieldKeyBindings;
// Allows overriding how tokens are copied to the clipboard, or retrieved from it.
@ -101,7 +102,10 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
const addSelectedItem = this._addSelectedItem.bind(this);
const openAutocomplete = this._openAutocomplete.bind(this);
this._acOptions = _options.acOptions && {..._options.acOptions, onClick: addSelectedItem};
this._tokens.set(_options.initialValue.map(t => new TokenWrap(t)));
const initialTokens = _options.initialValue;
this._maybeTrimLabels(initialTokens);
this._tokens.set(initialTokens.map(t => new TokenWrap(t)));
this.tokensObs = this.autoDispose(computedArray(this._tokens, t => t.token));
this._keyBindings = {...defaultKeyBindings, ..._options.keyBindings};
@ -191,12 +195,19 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
return this._textInput;
}
/**
* Returns the current value of the text input.
*/
public getTextInputValue(): string {
return this._options.trimLabels ? this._textInput.value.trim() : this._textInput.value;
}
// The invisible input that has focus while we have some tokens selected.
public getHiddenInput(): HTMLInputElement {
return this._hiddenInput;
}
// Replaces a token (if it exists)
// Replaces a token (if it exists).
public replaceToken(label: string, newToken: Token): void {
const tokenIdx = this._tokens.get().findIndex(t => t.token.label === label);
if (tokenIdx === -1) { return; }
@ -216,8 +227,9 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
// that; otherwise if options.createToken is present, creates a token from text input value.
private _addSelectedItem(): boolean {
let item: Token|undefined = this._acHolder.get()?.getSelectedItem();
if (!item && this._options.createToken && this._textInput.value) {
item = this._options.createToken(this._textInput.value);
const textInput = this.getTextInputValue();
if (!item && this._options.createToken && textInput) {
item = this._options.createToken(textInput);
}
if (item) {
this._tokens.push(new TokenWrap(item));
@ -421,6 +433,8 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
tokens = values.map(v => this._options.createToken(v)).filter((t): t is Token => Boolean(t));
}
if (!tokens.length) { return; }
this._maybeTrimLabels(tokens);
tokens = this._getNonEmptyTokens(tokens);
const wrappedTokens = tokens.map(t => new TokenWrap(t));
this._combineUndo(() => {
this._deleteTokens(this._selection.get(), 1);
@ -566,6 +580,25 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
}
}
}
/**
* Trims all labels in `tokens` if the option is set.
*
* Note: mutates `tokens`.
*/
private _maybeTrimLabels(tokens: Token[]): void {
if (!this._options.trimLabels) { return; }
tokens.forEach(t => {
t.label = t.label.trim();
});
}
/**
* Returns a filtered array of tokens that don't have empty labels.
*/
private _getNonEmptyTokens(tokens: Token[]): Token[] {
return tokens.filter(t => t.label !== '');
}
}
const cssTokenField = styled('div', `

View File

@ -11,14 +11,14 @@ import {RowId, RowSource} from 'app/client/models/rowset';
import {ColumnFilterFunc, SectionFilter} from 'app/client/models/SectionFilter';
import {TableData} from 'app/client/models/TableData';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {cssCheckboxSquare, cssLabel, cssLabelText, Indeterminate,
labeledTriStateSquareCheckbox} from 'app/client/ui2018/checkbox';
import {cssLabel as cssCheckboxLabel, cssCheckboxSquare, cssLabelText, Indeterminate, labeledTriStateSquareCheckbox
} from 'app/client/ui2018/checkbox';
import {colors, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menuCssClass, menuDivider} from 'app/client/ui2018/menus';
import {CellValue} from 'app/common/DocActions';
import {isEquivalentFilter} from "app/common/FilterState";
import {Computed, dom, DomElementMethod, IDisposableOwner, input, makeTestId, styled} from 'grainjs';
import {Computed, dom, DomElementArg, DomElementMethod, IDisposableOwner, input, makeTestId, styled} from 'grainjs';
import concat = require('lodash/concat');
import identity = require('lodash/identity');
import noop = require('lodash/noop');
@ -28,18 +28,22 @@ import tail = require('lodash/tail');
import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel';
import {decodeObject} from 'app/plugin/objtypes';
import {isList} from 'app/common/gristTypes';
import {choiceToken} from 'app/client/widgets/ChoiceToken';
import {ChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
import {cssInvalidToken} from 'app/client/widgets/ChoiceListCell';
interface IFilterMenuOptions {
model: ColumnFilterMenuModel;
valueCounts: Map<CellValue, IFilterCount>;
doSave: (reset: boolean) => void;
onClose: () => void;
renderValue: (key: CellValue, value: IFilterCount) => DomElementArg;
}
const testId = makeTestId('test-filter-menu-');
export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement {
const { model, doSave, onClose } = opts;
const { model, doSave, onClose, renderValue } = opts;
const { columnFilter } = model;
// Save the initial state to allow reverting back to it on Cancel
const initialStateJson = columnFilter.makeFilterJson();
@ -142,11 +146,14 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
dom.domComputed(filteredValues, (values) => values.slice(0, model.limitShown).map(([key, value]) => (
cssMenuItem(
cssLabel(
cssCheckboxSquare({type: 'checkbox'},
dom.on('change', (_ev, elem) =>
elem.checked ? columnFilter.add(key) : columnFilter.delete(key)),
(elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); }),
cssItemValue(value.label === undefined ? key as string : value.label),
cssCheckboxSquare(
{type: 'checkbox'},
dom.on('change', (_ev, elem) =>
elem.checked ? columnFilter.add(key) : columnFilter.delete(key)),
(elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); },
dom.style('position', 'relative'),
),
renderValue(key, value),
),
cssItemCount(value.count.toLocaleString(), testId('count')))
))) // Include comma separator
@ -311,9 +318,45 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
sectionFilter.resetTemporaryRows();
}
},
renderValue: getRenderFunc(columnType, field),
});
}
/**
* Returns a callback function for rendering values in a filter menu.
*
* For example, Choice and Choice List columns will differ from other
* column types by rendering their values as colored tokens instead of
* text.
*/
function getRenderFunc(columnType: string, field: ViewFieldRec) {
if (['Choice', 'ChoiceList'].includes(columnType)) {
const options = field.column().widgetOptionsJson.peek();
const choiceSet: Set<string> = new Set(options.choices || []);
const choiceOptions: ChoiceOptions = options.choiceOptions || {};
return (_key: CellValue, value: IFilterCount) => {
if (value.label === '') {
return cssItemValue(value.label);
}
return choiceToken(
value.label,
{
fillColor: choiceOptions[value.label]?.fillColor,
textColor: choiceOptions[value.label]?.textColor,
},
dom.cls(cssToken.className),
cssInvalidToken.cls('-invalid', !choiceSet.has(value.label)),
testId('choice-token')
);
};
}
return (key: CellValue, value: IFilterCount) =>
cssItemValue(value.label === undefined ? String(key) : value.label);
}
interface ICountOptions {
keyMapFunc?: (v: any) => any;
labelMapFunc?: (v: any) => any;
@ -439,10 +482,10 @@ const cssMenuItem = styled('div', `
display: flex;
padding: 8px 16px;
`);
const cssItemValue = styled(cssLabelText, `
export const cssItemValue = styled(cssLabelText, `
margin-right: 12px;
color: ${colors.dark};
white-space: nowrap;
white-space: pre;
`);
const cssItemCount = styled('div', `
flex-grow: 1;
@ -490,3 +533,11 @@ const cssSortIcon = styled(icon, `
--icon-color: ${colors.lightGreen}
}
`);
const cssLabel = styled(cssCheckboxLabel, `
align-items: center;
font-weight: initial; /* negate bootstrap */
`);
const cssToken = styled('div', `
margin-left: 8px;
margin-right: 12px;
`);

View File

@ -1,14 +1,14 @@
var _ = require('underscore');
var dispose = require('../lib/dispose');
var TextEditor = require('./TextEditor');
var dispose = require('app/client/lib/dispose');
var TextEditor = require('app/client/widgets/TextEditor');
const {Autocomplete} = require('app/client/lib/autocomplete');
const {ACIndexImpl, buildHighlightedDom} = require('app/client/lib/ACIndex');
const {ChoiceItem, cssChoiceList, cssItem, cssItemLabel, cssMatchText} = require('app/client/widgets/ChoiceListEditor');
const {cssRefList} = require('app/client/widgets/ReferenceEditor');
const {getFillColor, getTextColor} = require('app/client/widgets/ChoiceTextBox');
const {ChoiceItem, cssChoiceList, cssMatchText, cssPlusButton,
cssPlusIcon} = require('app/client/widgets/ChoiceListEditor');
const {menuCssClass} = require('app/client/ui2018/menus');
const {testId} = require('app/client/ui2018/cssVars');
const {choiceToken, cssChoiceACItem} = require('app/client/widgets/ChoiceToken');
const {dom} = require('grainjs');
/**
@ -19,6 +19,10 @@ function ChoiceEditor(options) {
this.choices = options.field.widgetOptionsJson.peek().choices || [];
this.choiceOptions = options.field.widgetOptionsJson.peek().choiceOptions || {};
// Whether to include a button to show a new choice.
// TODO: Disable when the user cannot change column configuration.
this.enableAddNew = true;
}
dispose.makeDisposable(ChoiceEditor);
@ -31,14 +35,16 @@ ChoiceEditor.prototype.getCellValue = function() {
ChoiceEditor.prototype.renderACItem = function(item, highlightFunc) {
const options = this.choiceOptions[item.label];
const fillColor = getFillColor(options);
const textColor = getTextColor(options);
return cssItem(
cssItemLabel(
return cssChoiceACItem(
(item.isNew ?
[cssChoiceACItem.cls('-new'), cssPlusButton(cssPlusIcon('Plus')), testId('choice-editor-new-item')] :
[cssChoiceACItem.cls('-with-new', this.showAddNew)]
),
choiceToken(
buildHighlightedDom(item.label, highlightFunc, cssMatchText),
dom.style('background-color', fillColor),
dom.style('color', textColor),
options || {},
dom.style('max-width', '100%'),
testId('choice-editor-item-label')
),
testId('choice-editor-item'),
@ -47,8 +53,8 @@ ChoiceEditor.prototype.renderACItem = function(item, highlightFunc) {
ChoiceEditor.prototype.attach = function(cellElem) {
TextEditor.prototype.attach.call(this, cellElem);
// Don't create autocomplete if readonly, or if there are no choices.
if (this.options.readonly || this.choices.length === 0) { return; }
// Don't create autocomplete if readonly.
if (this.options.readonly) { return; }
const acItems = this.choices.map(c => new ChoiceItem(c, false));
const acIndex = new ACIndexImpl(acItems);
@ -56,8 +62,8 @@ ChoiceEditor.prototype.attach = function(cellElem) {
popperOptions: {
placement: 'bottom'
},
menuCssClass: menuCssClass + ' ' + cssRefList.className + ' ' + cssChoiceList.className + ' test-autocomplete',
search: (term) => acIndex.search(term),
menuCssClass: `${menuCssClass} ${cssChoiceList.className} test-autocomplete`,
search: (term) => this.maybeShowAddNew(acIndex.search(term), term),
renderItem: (item, highlightFunc) => this.renderACItem(item, highlightFunc),
getItemText: (item) => item.label,
onClick: () => this.options.commands.fieldEditSave(),
@ -66,4 +72,39 @@ ChoiceEditor.prototype.attach = function(cellElem) {
this.autocomplete = Autocomplete.create(this, this.textInput, acOptions);
}
/**
* 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).
*/
ChoiceEditor.prototype.prepForSave = async function() {
const selectedItem = this.autocomplete && this.autocomplete.getSelectedItem();
if (selectedItem && selectedItem.isNew) {
const choices = this.options.field.widgetOptionsJson.prop('choices');
await choices.saveOnly([...choices.peek(), selectedItem.label]);
}
}
/**
* If the search text does not match anything exactly, adds 'new' item to it.
*
* Also see: prepForSave.
*/
ChoiceEditor.prototype.maybeShowAddNew = function(result, text) {
// TODO: This logic is also mostly duplicated in ChoiceListEditor and ReferenceEditor.
// See if there's anything common we can factor out and re-use.
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;
}
module.exports = ChoiceEditor;

View File

@ -3,12 +3,11 @@ import {colors, testId} from 'app/client/ui2018/cssVars';
import {
ChoiceOptionsByName,
ChoiceTextBox,
getFillColor,
getTextColor
} from 'app/client/widgets/ChoiceTextBox';
import {CellValue} from 'app/common/DocActions';
import {decodeObject} from 'app/plugin/objtypes';
import {Computed, dom, styled} from 'grainjs';
import {choiceToken} from 'app/client/widgets/ChoiceToken';
/**
* ChoiceListCell - A cell that renders a list of choice tokens.
@ -37,13 +36,13 @@ export class ChoiceListCell extends ChoiceTextBox {
// Handle any unexpected values we might get (non-array, or array with non-strings).
const tokens: unknown[] = Array.isArray(val) ? val : [val];
return tokens.map(token =>
cssToken(
String(token),
cssInvalidToken.cls('-invalid', !choiceSet.has(token as string)),
dom.style('background-color', getFillColor(choiceOptionsByName.get(String(token)))),
dom.style('color', getTextColor(choiceOptionsByName.get(String(token)))),
testId('choice-list-cell-token')
),
choiceToken(
String(token),
choiceOptionsByName.get(String(token)) || {},
cssInvalidToken.cls('-invalid', !choiceSet.has(String(token))),
dom.cls(cssToken.className),
testId('choice-list-cell-token')
)
);
}),
);
@ -67,9 +66,6 @@ const cssChoiceList = styled('div', `
const cssToken = styled('div', `
flex: 0 1 auto;
min-width: 0px;
overflow: hidden;
border-radius: 3px;
padding: 1px 4px;
margin: 2px;
line-height: 16px;
`);

View File

@ -2,18 +2,19 @@ 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, vars} from 'app/client/ui2018/cssVars';
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 {cssPlusButton, cssPlusIcon, cssRefList} from 'app/client/widgets/ReferenceEditor';
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();
@ -36,8 +37,8 @@ export class ChoiceListEditor extends NewBaseEditor {
private _inputSizer: HTMLElement; // Part of _contentSizer to size the text input
private _alignment: string;
// Whether to include a button to show a new choice. (It would make sense to disable it when
// user cannot change the column configuration.)
// 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;
@ -54,7 +55,7 @@ export class ChoiceListEditor extends NewBaseEditor {
const acIndex = new ACIndexImpl<ChoiceItem>(acItems);
const acOptions: IAutocompleteOptions<ChoiceItem> = {
menuCssClass: menuCssClass + ' ' + cssRefList.className + ' ' + cssChoiceList.className + ' test-autocomplete',
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,
@ -80,6 +81,7 @@ export class ChoiceListEditor extends NewBaseEditor {
acOptions,
openAutocompleteOnFocus: true,
readonly : options.readonly,
trimLabels: true,
styles: {cssTokenField, cssToken, cssDeleteButton, cssDeleteIcon},
});
@ -146,6 +148,10 @@ export class ChoiceListEditor extends NewBaseEditor {
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);
@ -202,33 +208,39 @@ export class ChoiceListEditor extends NewBaseEditor {
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> {
// If the search text does not match anything exactly, add 'new' item for it. See also prepForSave.
this._showAddNew = false;
if (this._enableAddNew && text) {
const addNewItem = new ChoiceItem(text, false, true);
if (!result.items.find((item) => item.cleanText === addNewItem.cleanText)) {
result.items.push(addNewItem);
this._showAddNew = true;
}
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];
const fillColor = getFillColor(options);
const textColor = getTextColor(options);
return cssItem(
return cssChoiceACItem(
(item.isNew ?
[cssItem.cls('-new'), cssPlusButton(cssPlusIcon('Plus'))] :
[cssItem.cls('-with-new', this._showAddNew)]
[cssChoiceACItem.cls('-new'), cssPlusButton(cssPlusIcon('Plus'))] :
[cssChoiceACItem.cls('-with-new', this._showAddNew)]
),
cssItemLabel(
choiceToken(
buildHighlightedDom(item.label, highlightFunc, cssMatchText),
dom.style('background-color', fillColor),
dom.style('color', textColor),
options || {},
dom.style('max-width', '100%'),
testId('choice-list-editor-item-label')
),
testId('choice-list-editor-item'),
@ -258,6 +270,7 @@ 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};
@ -316,7 +329,10 @@ const cssInputSizer = styled('div', `
// 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)
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', `
@ -324,48 +340,25 @@ const cssReadonlyStyle = styled('div', `
background: white;
`);
// We need to know the height of the sticky "+" element.
const addNewHeight = '37px';
export const cssItem = styled('li', `
display: block;
font-family: ${vars.fontFamily};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
outline: none;
padding: var(--weaseljs-menu-item-padding, 8px 24px);
cursor: pointer;
&.selected {
background-color: ${colors.mediumGreyOpaque};
color: ${colors.dark};
}
&-with-new {
scroll-margin-bottom: ${addNewHeight};
}
&-new {
display: flex;
align-items: center;
color: ${colors.slate};
position: sticky;
bottom: 0px;
height: ${addNewHeight};
background-color: white;
border-top: 1px solid ${colors.mediumGreyOpaque};
scroll-margin-bottom: initial;
}
&-new.selected {
color: ${colors.lightGrey};
}
`);
export const cssItemLabel = styled('div', `
display: inline-block;
padding: 1px 4px;
border-radius: 3px;
`);
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};
`);

View File

@ -4,8 +4,9 @@ import {Computed, Disposable, dom, DomContents, DomElementArg, Holder, Observabl
import {icon} from 'app/client/ui2018/icons';
import isEqual = require('lodash/isEqual');
import uniqBy = require('lodash/uniqBy');
import {IToken, TokenField} from '../lib/TokenField';
import {ChoiceOptionsByName, DEFAULT_TEXT_COLOR, IChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
import {IToken, TokenField} from 'app/client/lib/TokenField';
import {ChoiceOptionsByName, IChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
import {DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken';
import {colorButton} from 'app/client/ui2018/ColorSelect';
import {createCheckers, iface, ITypeSuite, opt} from 'ts-interface-checker';
@ -82,7 +83,7 @@ export class ChoiceListEntry extends Disposable {
return new ChoiceItem(label, this._choiceOptionsByName.get().get(label));
}),
renderToken: token => this._renderToken(token),
createToken: label => label.trim() !== '' ? new ChoiceItem(label.trim()) : undefined,
createToken: label => new ChoiceItem(label),
clipboardToTokens: clipboardToChoices,
tokensToClipboard: (tokens, clipboard) => {
// Save tokens as JSON for parts of the UI that support deserializing it properly (e.g. ChoiceListEntry).
@ -91,6 +92,7 @@ export class ChoiceListEntry extends Disposable {
clipboard.setData('text/plain', tokens.map(t => t.label).join('\n'));
},
openAutocompleteOnFocus: false,
trimLabels: true,
styles: {cssTokenField, cssToken, cssTokenInput, cssInputWrapper, cssDeleteButton, cssDeleteIcon},
keyBindings: {
previous: 'ArrowUp',
@ -116,7 +118,8 @@ export class ChoiceListEntry extends Disposable {
testId('choice-list-entry-cancel')
)
),
dom.onKeyDown({Escape$: () => this._save()}),
dom.onKeyDown({Escape$: () => this._cancel()}),
dom.onKeyDown({Enter$: () => this._save()}),
);
} else {
const someValues = Computed.create(null, this._values, (_use, values) =>
@ -168,7 +171,7 @@ export class ChoiceListEntry extends Disposable {
if (!tokenField) { return; }
const tokens = tokenField.tokensObs.get();
const tokenInputVal = tokenField.getTextInput().value.trim();
const tokenInputVal = tokenField.getTextInputValue();
if (tokenInputVal !== '') {
tokens.push(new ChoiceItem(tokenInputVal));
}
@ -266,7 +269,7 @@ function clipboardToChoices(clipboard: DataTransfer): ChoiceItem[] {
const maybeText = clipboard.getData('text/plain');
if (maybeText) {
return maybeText.split('\n').filter(t => t.trim() !== '').map(label => new ChoiceItem(label));
return maybeText.split('\n').map(label => new ChoiceItem(label));
}
return [];
@ -349,7 +352,7 @@ const cssTokenLabel = styled('span', `
margin-left: 6px;
display: inline-block;
text-overflow: ellipsis;
white-space: nowrap;
white-space: pre;
overflow: hidden;
`);

View File

@ -1,14 +1,12 @@
import * as commands from 'app/client/components/commands';
import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu, menuItem} from 'app/client/ui2018/menus';
import {testId} from 'app/client/ui2018/cssVars';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {Computed, dom, styled} from 'grainjs';
import {choiceToken, DEFAULT_FILL_COLOR, DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken';
export interface IChoiceOptions {
textColor: string;
@ -18,9 +16,6 @@ export interface IChoiceOptions {
export type ChoiceOptions = Record<string, IChoiceOptions | undefined>;
export type ChoiceOptionsByName = Map<string, IChoiceOptions | undefined>;
const DEFAULT_FILL_COLOR = colors.mediumGreyOpaque.value;
export const DEFAULT_TEXT_COLOR = '#000000';
export function getFillColor(choiceOptions?: IChoiceOptions) {
return choiceOptions?.fillColor ?? DEFAULT_FILL_COLOR;
}
@ -52,21 +47,20 @@ export class ChoiceTextBox extends NTextBox {
cssChoiceTextWrapper(
dom.style('justify-content', (use) => use(this.alignment) === 'right' ? 'flex-end' : use(this.alignment)),
dom.domComputed((use) => {
if (use(row._isAddRow)) { return cssChoiceText(''); }
if (use(row._isAddRow)) { return null; }
const formattedValue = use(this.valueFormatter).format(use(value));
if (formattedValue === '') { return cssChoiceText(''); }
if (formattedValue === '') { return null; }
const choiceOptions = use(this._choiceOptionsByName).get(formattedValue);
return cssChoiceText(
dom.style('background-color', getFillColor(choiceOptions)),
dom.style('color', getTextColor(choiceOptions)),
return choiceToken(
formattedValue,
choiceOptions || {},
dom.cls(cssChoiceText.className),
testId('choice-text')
);
}),
),
this.buildDropdownMenu(),
);
}
@ -102,27 +96,6 @@ export class ChoiceTextBox extends NTextBox {
protected getChoiceOptions(): Computed<ChoiceOptionsByName> {
return this._choiceOptionsByName;
}
protected buildDropdownMenu() {
return cssDropdownIcon('Dropdown',
// When choices exist, click dropdown icon to open edit autocomplete.
dom.on('click', () => this._hasChoices() && commands.allCommands.editField.run()),
// When choices do not exist, open a single-item menu to open the sidepane choice option editor.
menu(() => [
menuItem(commands.allCommands.fieldTabOpen.run, 'Add Choice Options')
], {
trigger: [(elem, ctl) => {
// Only open this menu if there are no choices.
dom.onElem(elem, 'click', () => this._hasChoices() || ctl.open());
}]
}),
testId('choice-dropdown')
);
}
private _hasChoices() {
return this._choiceValues.get().length > 0;
}
}
// Converts a POJO containing choice options to an ES6 Map
@ -142,8 +115,6 @@ function toObject(choiceOptions: ChoiceOptionsByName): ChoiceOptions {
}
const cssChoiceField = styled('div.field_clip', `
display: flex;
align-items: center;
padding: 0 3px;
`);
@ -155,20 +126,7 @@ const cssChoiceTextWrapper = styled('div', `
`);
const cssChoiceText = styled('div', `
border-radius: 3px;
padding: 1px 4px;
margin: 2px;
overflow: hidden;
text-overflow: ellipsis;
height: min-content;
line-height: 16px;
`);
const cssDropdownIcon = styled(icon, `
cursor: pointer;
background-color: ${colors.lightGreen};
min-width: 16px;
width: 16px;
height: 16px;
margin-left: auto;
`);

View File

@ -0,0 +1,81 @@
import {dom, DomContents, DomElementArg, styled} from "grainjs";
import {colors, vars} from "app/client/ui2018/cssVars";
export const DEFAULT_FILL_COLOR = colors.mediumGreyOpaque.value;
export const DEFAULT_TEXT_COLOR = '#000000';
export interface IChoiceTokenOptions {
fillColor?: string;
textColor?: string;
}
/**
* Creates a colored token representing a choice (e.g. Choice and Choice List values).
*
* Tokens are pill-shaped boxes that contain text, with custom fill and text
* colors. If colors are not specified, a gray fill with black text will be used.
*
* Additional styles and other DOM arguments can be passed in to customize the
* appearance and behavior of the token.
*
* @param {DomElementArg} label The text that will appear inside the token.
* @param {IChoiceTokenOptions} options Options for customizing the token appearance.
* @param {DOMElementArg[]} args Additional arguments to pass to the token.
* @returns {DomContents} A colored choice token.
*/
export function choiceToken(
label: DomElementArg,
{fillColor, textColor}: IChoiceTokenOptions,
...args: DomElementArg[]
): DomContents {
return cssChoiceToken(
label,
dom.style('background-color', fillColor ?? DEFAULT_FILL_COLOR),
dom.style('color', textColor ?? DEFAULT_TEXT_COLOR),
...args
);
}
const cssChoiceToken = styled('div', `
display: inline-block;
padding: 1px 4px;
border-radius: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: pre;
`);
const ADD_NEW_HEIGHT = '37px';
export const cssChoiceACItem = styled('li', `
display: block;
font-family: ${vars.fontFamily};
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
outline: none;
padding: var(--weaseljs-menu-item-padding, 8px 24px);
cursor: pointer;
&.selected {
background-color: ${colors.mediumGreyOpaque};
color: ${colors.dark};
}
&-with-new {
scroll-margin-bottom: ${ADD_NEW_HEIGHT};
}
&-new {
display: flex;
align-items: center;
color: ${colors.slate};
position: sticky;
bottom: 0px;
height: ${ADD_NEW_HEIGHT};
background-color: white;
border-top: 1px solid ${colors.mediumGreyOpaque};
scroll-margin-bottom: initial;
}
&-new.selected {
color: ${colors.lightGrey};
}
`);

View File

@ -48,7 +48,7 @@ export class ReferenceEditor extends NTextEditor {
// Whether we should enable the "Add New" entry to allow adding new items to the target table.
const vcol = field.visibleColModel();
this._enableAddNew = vcol && !vcol.isRealFormula();
this._enableAddNew = vcol && !vcol.isRealFormula() && !!vcol.colId();
this._visibleCol = vcol.colId() || 'id';
@ -144,18 +144,26 @@ export class ReferenceEditor extends NTextEditor {
return String(value || '');
}
/**
* If the search text does not match anything exactly, adds 'new' item to it.
*
* Also see: prepForSave.
*/
private async _doSearch(text: string): Promise<ACResults<ICellItem>> {
const acIndex = this._tableData.columnACIndexes.getColACIndex(this._visibleCol, this._formatter);
const result = acIndex.search(text);
// If the search text does not match anything exactly, add 'new' item for it. See also prepForSave.
this._showAddNew = false;
if (this._enableAddNew && text) {
const cleanText = text.trim().toLowerCase();
if (!result.items.find((item) => item.cleanText === cleanText)) {
result.items.push({rowId: 'new', text, cleanText});
this._showAddNew = true;
}
if (!this._enableAddNew || !text) { return result; }
const cleanText = text.trim().toLowerCase();
if (result.items.find((item) => item.cleanText === cleanText)) {
return result;
}
result.items.push({rowId: 'new', text, cleanText});
this._showAddNew = true;
return result;
}
@ -187,7 +195,7 @@ const cssRefEditor = styled('div', `
}
`);
export const cssRefList = styled('div', `
const cssRefList = styled('div', `
overflow-y: auto;
padding: 8px 0 0 0;
--weaseljs-menu-item-padding: 8px 16px;
@ -199,7 +207,7 @@ const addNewHeight = '37px';
const cssRefItem = styled('li', `
display: block;
font-family: ${vars.fontFamily};
white-space: nowrap;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
outline: none;
@ -227,7 +235,7 @@ const cssRefItem = styled('li', `
}
`);
export const cssPlusButton = styled('div', `
const cssPlusButton = styled('div', `
display: inline-block;
width: 20px;
height: 20px;
@ -242,7 +250,7 @@ export const cssPlusButton = styled('div', `
}
`);
export const cssPlusIcon = styled(icon, `
const cssPlusIcon = styled(icon, `
background-color: ${colors.light};
`);