(core) Add color options to choice config UI

Summary:
Includes overhauled choice configuration UI for choice and choice list
columns based on the TokenField library. Features include rich copy
and paste support, keyboard shortcuts for token manipulation, and
drag-and-drop support for arrangement.

Configured choice colors are visible throughout the application, such
as in the autocomplete window for both choice and choice list cells, and
in table cells directly.

Choice cells in particular are now styled closer to choice list cells,
and render their contents as colored tokens. Choice cells now also
use the improved autocomplete component that choice lists use, with
some room for future improvement (e.g. allowing new choice items to be
added inline like in choice list's autocomplete).

Also includes a minor fix for choice list cells where right align
was not working.

Test Plan: Browser tests updated.

Reviewers: jarek, dsagal

Reviewed By: jarek, dsagal

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D2890
This commit is contained in:
George Gevoian 2021-07-08 14:35:16 -07:00
parent 11c944d794
commit 2f900f68f8
11 changed files with 768 additions and 306 deletions

View File

@ -85,7 +85,7 @@ export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRe
case 'Choice': {
if (Array.isArray(prevOptions.choices)) {
// Use previous choices if they are set, e.g. if converting from ChoiceList
widgetOptions = {choices: prevOptions.choices};
widgetOptions = {choices: prevOptions.choices, choiceOptions: prevOptions.choiceOptions};
} else {
// Set suggested choices. Limit to 100, since too many choices is more likely to cause
// trouble than desired behavior. For many choices, recommend using a Ref to helper table.
@ -100,7 +100,7 @@ export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRe
case 'ChoiceList': {
if (Array.isArray(prevOptions.choices)) {
// Use previous choices if they are set, e.g. if converting from ChoiceList
widgetOptions = {choices: prevOptions.choices};
widgetOptions = {choices: prevOptions.choices, choiceOptions: prevOptions.choiceOptions};
} else {
// Set suggested choices. This happens before the conversion to ChoiceList, so we do some
// light guessing for likely choices to suggest.

View File

@ -21,83 +21,97 @@ import { Autocomplete, IAutocompleteOptions } from 'app/client/lib/autocomplete'
import { colors, testId } from 'app/client/ui2018/cssVars';
import { icon } from 'app/client/ui2018/icons';
import { csvDecodeRow, csvEncodeRow } from 'app/common/csvFormat';
import { computedArray, IObsArraySplice, ObsArray, obsArray, Observable } from 'grainjs';
import { computedArray, IDisposableCtor, IObsArraySplice, ObsArray, obsArray, Observable } from 'grainjs';
import { Disposable, dom, DomElementArg, Holder, styled } from 'grainjs';
export interface IToken {
label: string;
}
export interface ITokenFieldOptions {
initialValue: IToken[];
renderToken: (token: IToken) => DomElementArg;
createToken: (inputText: string) => IToken|undefined;
acOptions?: IAutocompleteOptions<IToken & ACItem>;
export interface ITokenFieldOptions<Token extends IToken> {
initialValue: Token[];
renderToken: (token: Token) => DomElementArg;
createToken: (inputText: string) => Token|undefined;
acOptions?: IAutocompleteOptions<Token & ACItem>;
openAutocompleteOnFocus?: boolean;
styles?: ITokenFieldStyles;
readonly?: boolean;
keyBindings?: ITokenFieldKeyBindings;
// Allows overriding how tokens are copied to the clipboard, or retrieved from it.
// By default, tokens are placed into clipboard as text/plain comma-separated token labels, with
// CSV escaping, and pasted from clipboard by applying createToken() to parsed CSV text.
tokensToClipboard?: (tokens: IToken[], clipboard: DataTransfer) => void;
clipboardToTokens?: (clipboard: DataTransfer) => IToken[];
tokensToClipboard?: (tokens: Token[], clipboard: DataTransfer) => void;
clipboardToTokens?: (clipboard: DataTransfer) => Token[];
}
/**
* Overrides for default TokenField shortcut bindings.
*
* Values should be Key Values (https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values).
*/
export interface ITokenFieldKeyBindings {
previous?: string;
next?: string;
}
const defaultKeyBindings: Required<ITokenFieldKeyBindings> = {
previous: 'ArrowLeft',
next: 'ArrowRight'
};
// TokenWrap serves to distinguish multiple instances of the same token in the list.
class TokenWrap {
constructor(public token: IToken) {}
class TokenWrap<Token extends IToken> {
constructor(public token: Token) {}
}
class UndoItem {
constructor(public redo: () => void, public undo: () => void) {}
}
export class TokenField extends Disposable {
public tokensObs: ObsArray<IToken>;
export class TokenField<Token extends IToken = IToken> extends Disposable {
public static ctor<T extends IToken>(): IDisposableCtor<TokenField<T>, [ITokenFieldOptions<T>]> {
return this;
}
private _acHolder = Holder.create<Autocomplete<IToken & ACItem>>(this);
private _acOptions: IAutocompleteOptions<IToken & ACItem>|undefined;
public tokensObs: ObsArray<Token>;
private _acHolder = Holder.create<Autocomplete<Token & ACItem>>(this);
private _acOptions: IAutocompleteOptions<Token & ACItem>|undefined;
private _rootElem: HTMLElement;
private _textInput: HTMLInputElement;
private _styles: Required<ITokenFieldStyles>;
// ClipboardAPI events work as expected only when the focus is in an actual input.
// This is where we place focus when we have some tokens selected.
private _hiddenInput: HTMLInputElement;
// Keys to navigate tokens. In a vertical list, these would be changed to Up/Down.
// TODO Support a vertical list.
private _keyPrev = 'ArrowLeft';
private _keyNext = 'ArrowRight';
private _keyBindings: Required<ITokenFieldKeyBindings>;
private _tokens = this.autoDispose(obsArray<TokenWrap>());
private _selection = Observable.create(this, new Set<TokenWrap>());
private _selectionAnchor: TokenWrap|null = null;
private _tokens = this.autoDispose(obsArray<TokenWrap<Token>>());
private _selection = Observable.create(this, new Set<TokenWrap<Token>>());
private _selectionAnchor: TokenWrap<Token>|null = null;
private _undoStack: UndoItem[] = [];
private _undoIndex = 0; // The last action done; next to undo.
private _inUndoRedo = false;
constructor(private _options: ITokenFieldOptions) {
constructor(private _options: ITokenFieldOptions<Token>) {
super();
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)));
this.tokensObs = this.autoDispose(computedArray(this._tokens, t => t.token));
this._keyBindings = {...defaultKeyBindings, ..._options.keyBindings};
// We can capture undo info in a consistent way as long as we change _tokens using its
// obsArray interface, by listening to the splice events.
this.autoDispose(this._tokens.addListener(this._recordUndo.bind(this)));
// Use overridden styles if any were provided.
const {
cssTokenField,
cssToken,
cssInputWrapper,
cssTokenInput,
cssDeleteButton,
cssDeleteIcon} =
{...tokenFieldStyles, ..._options.styles};
this._styles = {...tokenFieldStyles, ..._options.styles};
const {cssTokenField, cssToken, cssInputWrapper, cssTokenInput, cssDeleteButton, cssDeleteIcon} = this._styles;
function stop(ev: Event) {
ev.stopPropagation();
@ -141,8 +155,8 @@ export class TokenField extends Disposable {
a$: this._maybeSelectAllTokens.bind(this),
Backspace$: this._maybeBackspace.bind(this),
Delete$: this._maybeDelete.bind(this),
[this._keyPrev + '$']: (ev) => this._maybeAdvance(ev, -1),
[this._keyNext + '$']: (ev) => this._maybeAdvance(ev, +1),
[this._keyBindings.previous + '$']: (ev) => this._maybeAdvance(ev, -1),
[this._keyBindings.next + '$']: (ev) => this._maybeAdvance(ev, +1),
// ['Mod+z'] triggers undo; ['Mod+Shift+Z', 'Ctrl+y' ] trigger redo
z$: (ev) => { if (ev[modKeyProp()]) { ev.shiftKey ? this._redo(ev) : this._undo(ev); } },
y$: (ev) => { if (ev.ctrlKey && !ev.shiftKey) { this._redo(ev); } },
@ -155,7 +169,7 @@ export class TokenField extends Disposable {
}
}),
),
dom.on('focus', () => this._hiddenInput.focus()),
dom.on('focus', () => this._hiddenInput.focus({preventScroll: true})),
dom.on('copy', this._onCopyEvent.bind(this)),
dom.on('cut', this._onCutEvent.bind(this)),
dom.on('paste', this._onPasteEvent.bind(this)),
@ -182,6 +196,13 @@ export class TokenField extends Disposable {
return this._hiddenInput;
}
// 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; }
this._tokens.splice(tokenIdx, 1, new TokenWrap(newToken));
}
// Open the autocomplete dropdown, if autocomplete was configured in the options.
private _openAutocomplete() {
// don't open dropdown in a readonly mode
@ -194,7 +215,7 @@ export class TokenField extends Disposable {
// Adds the typed-in or selected item. If an item is selected in autocomplete dropdown, adds
// that; otherwise if options.createToken is present, creates a token from text input value.
private _addSelectedItem(): boolean {
let item: IToken|undefined = this._acHolder.get()?.getSelectedItem();
let item: Token|undefined = this._acHolder.get()?.getSelectedItem();
if (!item && this._options.createToken && this._textInput.value) {
item = this._options.createToken(this._textInput.value);
}
@ -218,10 +239,10 @@ export class TokenField extends Disposable {
// Handle for a click on a token or the token's delete button. This handles selection, including
// Shift+Click and Ctrl+Click.
private _onTokenClick(ev: MouseEvent, t: TokenWrap) {
private _onTokenClick(ev: MouseEvent, t: TokenWrap<Token>) {
const idx = this._tokens.get().indexOf(t);
if (idx < 0) { return; }
if (ev.target && (ev.target as HTMLElement).matches('.' + cssDeleteIcon.className)) {
if (ev.target && (ev.target as HTMLElement).matches('.' + this._styles.cssDeleteIcon.className)) {
// Delete token.
this._tokens.splice(idx, 1);
} else {
@ -266,6 +287,7 @@ export class TokenField extends Disposable {
if (this._textInput.value === '') {
ev.stopPropagation();
ev.preventDefault();
if (ev.repeat) { return; }
if (this._selection.get().size === 0) {
this._tokens.pop();
} else {
@ -307,7 +329,7 @@ export class TokenField extends Disposable {
}
} else {
// For arrow keys, move to the next token after the selection.
let next: TokenWrap|null = null;
let next: TokenWrap<Token>|null = null;
if (this._selection.get().size > 0) {
next = this._getNextToken(this._selection.get(), advance);
} else if (advance < 0 && tokens.length > 0) {
@ -323,7 +345,7 @@ export class TokenField extends Disposable {
}
}
private _toggleTokenSelection(token: TokenWrap) {
private _toggleTokenSelection(token: TokenWrap<Token>) {
const selection = this._selection.get();
if (selection.has(token)) {
selection.delete(token);
@ -334,13 +356,13 @@ export class TokenField extends Disposable {
this._selection.setAndTrigger(selection);
}
private _resetTokenSelection(token: TokenWrap|null) {
private _resetTokenSelection(token: TokenWrap<Token>|null) {
this._selectionAnchor = token;
this._selection.set(token ? new Set([token]) : new Set());
}
// Delete the given set of tokens, and select either the following or the preceding one.
private _deleteTokens(toDelete: Set<TokenWrap>, advance: 1|-1|0) {
private _deleteTokens(toDelete: Set<TokenWrap<Token>>, advance: 1|-1|0) {
if (this._selection.get().size === 0) { return; }
const selectAfter = advance ? this._getNextToken(toDelete, advance) : null;
this._tokens.set(this._tokens.get().filter(t => !toDelete.has(t)));
@ -348,13 +370,13 @@ export class TokenField extends Disposable {
this._setFocus();
}
private _getNextToken(selection: Set<TokenWrap>, advance: 1|-1): TokenWrap|null {
private _getNextToken(selection: Set<TokenWrap<Token>>, advance: 1|-1): TokenWrap<Token>|null {
const [first, last] = this._getSelectedIndexRange(selection);
if (last < 0) { return null; }
return this._tokens.get()[advance > 0 ? last + 1 : first - 1] || null;
}
private _getSelectedIndexRange(selection: Set<TokenWrap>): [number, number] {
private _getSelectedIndexRange(selection: Set<TokenWrap<Token>>): [number, number] {
const tokens = this._tokens.get();
let first = -1, last = -1;
for (let i = 0; i < tokens.length; i++) {
@ -390,13 +412,13 @@ export class TokenField extends Disposable {
private _onPasteEvent(ev: ClipboardEvent) {
if (!ev.clipboardData) { return; }
ev.preventDefault();
let tokens: IToken[];
let tokens: Token[];
if (this._options.clipboardToTokens) {
tokens = this._options.clipboardToTokens(ev.clipboardData);
} else {
const text = ev.clipboardData.getData('text/plain');
const values = csvDecodeRow(text);
tokens = values.map(v => this._options.createToken(v)).filter((t): t is IToken => Boolean(t));
tokens = values.map(v => this._options.createToken(v)).filter((t): t is Token => Boolean(t));
}
if (!tokens.length) { return; }
const wrappedTokens = tokens.map(t => new TokenWrap(t));
@ -417,10 +439,10 @@ export class TokenField extends Disposable {
// For a mousedown on a token, register events for mousemove/mouseup, and start dragging as soon
// as mousemove occurs.
private _onMouseDown(startEvent: MouseEvent, t: TokenWrap) {
private _onMouseDown(startEvent: MouseEvent, t: TokenWrap<Token>) {
const xInitial = startEvent.clientX;
const yInitial = startEvent.clientY;
const dragTargetSelector = `.${cssToken.className}, .${cssInputWrapper.className}`;
const dragTargetSelector = `.${this._styles.cssToken.className}, .${this._styles.cssInputWrapper.className}`;
let started = false;
let allTargets: HTMLElement[];
@ -468,7 +490,7 @@ export class TokenField extends Disposable {
// end (just before or over the input box), destToken will be undefined.
const index = allTargets.indexOf(ev.target as HTMLElement);
if (index < 0) { return; }
const destToken: TokenWrap|undefined = this._tokens.get()[index];
const destToken: TokenWrap<Token>|undefined = this._tokens.get()[index];
const selection = this._selection.get();
if (selection.has(destToken)) { return; } // Not actually moving anywhere new.
@ -491,7 +513,7 @@ export class TokenField extends Disposable {
const stopLis = dom.onElem(document, 'mouseup', onStop, {useCapture: true});
}
private _recordUndo(val: TokenWrap[], prev: TokenWrap[], change?: IObsArraySplice<TokenWrap>) {
private _recordUndo(val: TokenWrap<Token>[], prev: TokenWrap<Token>[], change?: IObsArraySplice<TokenWrap<Token>>) {
if (this._inUndoRedo) { return; }
const splice = change || {start: 0, numAdded: val.length, deleted: [...prev]};
const newTokens = val.slice(splice.start, splice.start + splice.numAdded);

View File

@ -1,212 +0,0 @@
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {Computed, Disposable, dom, DomContents, DomElementArg, Observable, styled} from 'grainjs';
import isEqual = require('lodash/isEqual');
import uniq = require('lodash/uniq');
/**
* ListEntry class to build a textarea of unique newline separated values, with a nice
* display mode when the values are not being edited.
*
* Usage:
* > dom.create(ListEntry, values, (vals) => choices.saveOnly(vals));
*/
export class ListEntry extends Disposable {
// Should start in edit mode if there are no initial values.
private _isEditing: Observable<boolean> = Observable.create(this, this._values.get().length === 0);
private _textVal: Observable<string> = Observable.create(this, "");
constructor(
private _values: Observable<string[]>,
private _onSave: (values: string[]) => void
) {
super();
// Since the saved values can be modified outside the ListEntry (via undo/redo),
// add a listener to update edit status on changes.
this.autoDispose(this._values.addListener(values => {
if (values.length === 0) { this._textVal.set(""); }
this._isEditing.set(values.length === 0);
}));
}
// Arg maxRows indicates the number of rows to display when the textarea is inactive.
public buildDom(maxRows: number = 6): DomContents {
return dom.domComputed(this._isEditing, (editMode) => {
if (editMode) {
// Edit mode dom.
let textArea: HTMLTextAreaElement;
return cssVerticalFlex(
cssListBox(
textArea = cssListTextArea(
dom.prop('value', this._textVal),
dom.on('input', (ev, elem) => this._textVal.set(elem.value)),
(elem) => this._focusOnOpen(elem),
dom.on('blur', (ev, elem) => { setTimeout(() => this._save(elem), 0); }),
dom.onKeyDown({Escape: (ev, elem) => this._save(elem)}),
// Keep height to be two rows taller than the number of text rows
dom.style('height', (use) => {
const rows = use(this._textVal).split('\n').length;
return `${(rows + 2) * 22}px`;
})
),
cssHelpLine(
cssIdeaIcon('Idea'), 'Type one option per line'
),
testId('list-entry')
),
// Show buttons if the textArea has or had valid text content
dom.maybe((use) => use(this._values).length > 0 || use(this._textVal).trim().length > 0, () =>
cssButtonRow(
primaryButton('Save', {style: 'margin-right: 8px;'},
// Prevent textarea focus loss on mousedown
dom.on('mousedown', (ev) => ev.preventDefault()),
dom.on('click', () => this._save(textArea)),
testId('list-entry-save')
),
basicButton('Cancel',
// Prevent textarea focus loss on mousedown
dom.on('mousedown', (ev) => ev.preventDefault()),
dom.on('click', () => this._cancel()),
testId('list-entry-cancel')
)
)
)
);
} else {
// Inactive display dom.
const someValues = Computed.create(null, this._values, (use, values) =>
values.length <= maxRows ? values : values.slice(0, maxRows - 1));
return cssListBoxInactive(
dom.autoDispose(someValues),
dom.forEach(someValues, val => this._row(val)),
// Show description row for any remaining rows
dom.maybe(use => use(this._values).length > maxRows, () =>
this._row(
dom.text((use) => `+${use(this._values).length - (maxRows - 1)} more`)
)
),
dom.on('click', () => this._startEditing()),
testId('list-entry')
);
}
});
}
// Build a display row with the given text value
private _row(...domArgs: DomElementArg[]): Element {
return cssListRow(
...domArgs,
testId('list-entry-row')
);
}
// Indicates whether the listEntry currently has saved values.
private _hasValues(): boolean {
return this._values.get().length > 0;
}
private _startEditing(): void {
this._textVal.set(this._hasValues() ? (this._values.get().join('\n') + '\n') : '');
this._isEditing.set(true);
}
private _save(elem: HTMLTextAreaElement): void {
if (!this._isEditing.get()) { return; }
const newValues = uniq(
elem.value.split('\n')
.map(val => val.trim())
.filter(val => val !== '')
);
// Call user save function if the values have changed.
if (!isEqual(this._values.get(), newValues)) {
// Because of the listener on this._values, editing will stop if values are updated.
this._onSave(newValues);
} else {
this._cancel();
}
}
private _cancel(): void {
if (this._hasValues()) {
this._isEditing.set(false);
} else {
this._textVal.set("");
}
}
private _focusOnOpen(elem: HTMLTextAreaElement): void {
// Do not grab focus if the textArea is empty, since it indicates that the listEntry
// started in edit mode, and was not set to be so by the user.
if (this._textVal.get()) {
setTimeout(() => focus(elem), 0);
}
}
}
// Helper to focus on the textarea and select/scroll to the bottom
function focus(elem: HTMLTextAreaElement) {
elem.focus();
elem.setSelectionRange(elem.value.length, elem.value.length);
elem.scrollTo(0, elem.scrollHeight);
}
const cssListBox = styled('div', `
width: 100%;
background-color: white;
padding: 1px;
border: 1px solid ${colors.hover};
border-radius: 4px;
`);
const cssListBoxInactive = styled(cssListBox, `
cursor: pointer;
border: 1px solid ${colors.darkGrey};
&:hover {
border: 1px solid ${colors.hover};
}
`);
const cssListTextArea = styled('textarea', `
width: 100%;
max-height: 150px;
padding: 2px 12px;
line-height: 22px;
border: none;
outline: none;
resize: none;
`);
const cssListRow = styled('div', `
margin: 4px;
padding: 4px 8px;
color: ${colors.dark};
background-color: ${colors.mediumGrey};
border-radius: 4px;
overflow: hidden;
text-overflow: ellipsis;
`);
const cssHelpLine = styled('div', `
display: flex;
margin: 2px 8px 8px 8px;
color: ${colors.slate};
`);
const cssIdeaIcon = styled(icon, `
background-color: ${colors.lightGreen};
margin-right: 4px;
`);
const cssVerticalFlex = styled('div', `
width: 100%;
display: flex;
flex-direction: column;
`);
const cssButtonRow = styled('div', `
display: flex;
margin: 16px 0;
`);

View File

@ -36,6 +36,25 @@ export function colorSelect(textColor: Observable<string>, fillColor: Observable
return selectBtn;
}
export function colorButton(textColor: Observable<string>, fillColor: Observable<string>,
onSave: () => Promise<void>): Element {
const iconBtn = cssIconBtn(
icon(
'Dropdown',
dom.style('background-color', textColor),
testId('color-button-dropdown')
),
dom.style('background-color', (use) => use(fillColor).slice(0, 7)),
dom.on('click', (e) => { e.stopPropagation(); e.preventDefault(); }),
testId('color-button'),
);
const domCreator = (ctl: IOpenController) => buildColorPicker(ctl, textColor, fillColor, onSave);
setPopupToCreateDom(iconBtn, domCreator, { ...defaultMenuOptions, placement: 'bottom-end' });
return iconBtn;
}
function buildColorPicker(ctl: IOpenController, textColor: Observable<string>, fillColor: Observable<string>,
onSave: () => Promise<void>): Element {
const textColorModel = PickerModel.create(null, textColor);
@ -318,3 +337,13 @@ const cssButtonIcon = styled(cssColorSquare, `
margin-right: 6px;
margin-left: 4px;
`);
const cssIconBtn = styled('div', `
min-width: 18px;
width: 18px;
height: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
`);

View File

@ -2,7 +2,14 @@ var _ = require('underscore');
var dispose = require('../lib/dispose');
var TextEditor = require('./TextEditor');
const {autocomplete} = require('app/client/ui2018/menus');
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 {menuCssClass} = require('app/client/ui2018/menus');
const {testId} = require('app/client/ui2018/cssVars');
const {dom} = require('grainjs');
/**
* ChoiceEditor - TextEditor with a dropdown for possible choices.
@ -11,17 +18,52 @@ function ChoiceEditor(options) {
TextEditor.call(this, options);
this.choices = options.field.widgetOptionsJson.peek().choices || [];
// Add autocomplete if there are any choices to select from
if (this.choices.length > 0 && !options.readonly) {
autocomplete(this.textInput, this.choices, {
allowNothingSelected: true,
onClick: () => this.options.commands.fieldEditSave(),
});
}
this.choiceOptions = options.field.widgetOptionsJson.peek().choiceOptions || {};
}
dispose.makeDisposable(ChoiceEditor);
_.extend(ChoiceEditor.prototype, TextEditor.prototype);
ChoiceEditor.prototype.getCellValue = function() {
const selectedItem = this.autocomplete && this.autocomplete.getSelectedItem();
return selectedItem ? selectedItem.label : TextEditor.prototype.getCellValue.call(this);
}
ChoiceEditor.prototype.renderACItem = function(item, highlightFunc) {
const options = this.choiceOptions[item.label];
const fillColor = getFillColor(options);
const textColor = getTextColor(options);
return cssItem(
cssItemLabel(
buildHighlightedDom(item.label, highlightFunc, cssMatchText),
dom.style('background-color', fillColor),
dom.style('color', textColor),
testId('choice-editor-item-label')
),
testId('choice-editor-item'),
);
}
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; }
const acItems = this.choices.map(c => new ChoiceItem(c, false));
const acIndex = new ACIndexImpl(acItems);
const acOptions = {
popperOptions: {
placement: 'bottom'
},
menuCssClass: menuCssClass + ' ' + cssRefList.className + ' ' + cssChoiceList.className + ' test-autocomplete',
search: (term) => acIndex.search(term),
renderItem: (item, highlightFunc) => this.renderACItem(item, highlightFunc),
getItemText: (item) => item.label,
onClick: () => this.options.commands.fieldEditSave(),
};
this.autocomplete = Autocomplete.create(this, this.textInput, acOptions);
}
module.exports = ChoiceEditor;

View File

@ -1,6 +1,11 @@
import {DataRowModel} from 'app/client/models/DataRowModel';
import {colors} from 'app/client/ui2018/cssVars';
import {ChoiceTextBox} from 'app/client/widgets/ChoiceTextBox';
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';
@ -17,21 +22,28 @@ export class ChoiceListCell extends ChoiceTextBox {
return cssChoiceList(
dom.cls('field_clip'),
cssChoiceList.cls('-wrap', this.wrapping),
dom.style('justify-content', this.alignment),
dom.style('justify-content', use => use(this.alignment) === 'right' ? 'flex-end' : use(this.alignment)),
dom.domComputed((use) => {
return use(row._isAddRow) ? null : [use(value), use(this._choiceSet)] as [CellValue, Set<string>];
return use(row._isAddRow) ? null :
[
use(value), use(this._choiceSet),
use(this.getChoiceOptions())
] as [CellValue, Set<string>, ChoiceOptionsByName];
}, (input) => {
if (!input) { return null; }
const [rawValue, choiceSet] = input;
const [rawValue, choiceSet, choiceOptionsByName] = input;
const val = decodeObject(rawValue);
if (!val) { return null; }
// 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))
)
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')
),
);
}),
);
@ -57,7 +69,6 @@ const cssToken = styled('div', `
min-width: 0px;
overflow: hidden;
border-radius: 3px;
background-color: ${colors.mediumGreyOpaque};
padding: 1px 4px;
margin: 2px;
line-height: 16px;
@ -69,7 +80,4 @@ export const cssInvalidToken = styled('div', `
box-shadow: inset 0 0 0 1px var(--grist-color-error);
color: ${colors.slate};
}
&-invalid.selected {
background-color: ${colors.lightGrey} !important;
}
`);

View File

@ -1,20 +1,21 @@
import {createGroup} from 'app/client/components/commands';
import {ACIndexImpl, ACItem, ACResults} from 'app/client/lib/ACIndex';
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 {colors, testId, vars} 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 {cssRefList, renderACItem} from 'app/client/widgets/ReferenceEditor';
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';
class ChoiceItem implements ACItem, IToken {
export class ChoiceItem implements ACItem, IToken {
public cleanText: string = this.label.toLowerCase().trim();
constructor(
public label: string,
@ -27,7 +28,7 @@ export class ChoiceListEditor extends NewBaseEditor {
protected cellEditorDiv: HTMLElement;
protected commandGroup: any;
private _tokenField: TokenField;
private _tokenField: TokenField<ChoiceItem>;
private _textInput: HTMLInputElement;
private _dom: HTMLElement;
private _editorPlacement: EditorPlacement;
@ -40,10 +41,14 @@ export class ChoiceListEditor extends NewBaseEditor {
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);
@ -51,8 +56,7 @@ export class ChoiceListEditor extends NewBaseEditor {
const acOptions: IAutocompleteOptions<ChoiceItem> = {
menuCssClass: menuCssClass + ' ' + cssRefList.className + ' ' + cssChoiceList.className + ' test-autocomplete',
search: async (term: string) => this._maybeShowAddNew(acIndex.search(term), term),
renderItem: (item: ChoiceItem, highlightFunc) =>
renderACItem(item.label, highlightFunc, item.isNew || false, this._showAddNew),
renderItem: (item, highlightFunc) => this._renderACItem(item, highlightFunc),
getItemText: (item) => item.label,
};
@ -64,9 +68,14 @@ export class ChoiceListEditor extends NewBaseEditor {
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.create(this, {
this._tokenField = TokenField.ctor<ChoiceItem>().create(this, {
initialValue: startTokens,
renderToken: item => [item.label, cssInvalidToken.cls('-invalid', (item as ChoiceItem).isInvalid)],
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,
@ -138,7 +147,7 @@ export class ChoiceListEditor extends NewBaseEditor {
}
public async prepForSave() {
const tokens = this._tokenField.tokensObs.get() as ChoiceItem[];
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');
@ -205,6 +214,27 @@ export class ChoiceListEditor extends NewBaseEditor {
}
return result;
}
private _renderACItem(item: ChoiceItem, highlightFunc: HighlightFunc) {
const options = this._choiceOptionsByName[item.label];
const fillColor = getFillColor(options);
const textColor = getTextColor(options);
return cssItem(
(item.isNew ?
[cssItem.cls('-new'), cssPlusButton(cssPlusIcon('Plus'))] :
[cssItem.cls('-with-new', this._showAddNew)]
),
cssItemLabel(
buildHighlightedDom(item.label, highlightFunc, cssMatchText),
dom.style('background-color', fillColor),
dom.style('color', textColor),
testId('choice-list-editor-item-label')
),
testId('choice-list-editor-item'),
item.isNew ? testId('choice-list-editor-new-item') : null,
);
}
}
const cssCellEditor = styled('div', `
@ -228,6 +258,10 @@ const cssToken = styled(tokenFieldStyles.cssToken, `
padding: 1px 4px;
margin: 2px;
line-height: 16px;
&.selected {
box-shadow: inset 0 0 0 1px ${colors.lightGreen};
}
`);
const cssDeleteButton = styled(tokenFieldStyles.cssDeleteButton, `
@ -280,7 +314,7 @@ const cssInputSizer = styled('div', `
`);
// Set z-index to be higher than the 1000 set for .cell_editor.
const cssChoiceList = styled('div', `
export const cssChoiceList = styled('div', `
z-index: 1001;
box-shadow: 0 0px 8px 0 rgba(38,38,51,0.6)
`);
@ -289,3 +323,49 @@ const cssReadonlyStyle = styled('div', `
padding-left: 16px;
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;
`);

View File

@ -0,0 +1,411 @@
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {Computed, Disposable, dom, DomContents, DomElementArg, Holder, Observable, styled} from 'grainjs';
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 {colorButton} from 'app/client/ui2018/ColorSelect';
import {createCheckers, iface, ITypeSuite, opt} from 'ts-interface-checker';
class ChoiceItem implements IToken {
constructor(
public label: string,
public options?: IChoiceOptions,
) {}
}
const ChoiceItemType = iface([], {
label: "string",
options: opt("ChoiceOptionsType"),
});
const ChoiceOptionsType = iface([], {
textColor: "string",
fillColor: "string",
});
const choiceTypes: ITypeSuite = {
ChoiceItemType,
ChoiceOptionsType,
};
const {ChoiceItemType: ChoiceItemChecker} = createCheckers(choiceTypes);
const UNSET_COLOR = '#ffffff';
/**
* ChoiceListEntry - Editor for choices and choice colors.
*
* The ChoiceListEntry can be in one of two modes: edit or view (default).
*
* When in edit mode, it displays a custom, vertical TokenField that allows for entry
* of new choice values. Once changes are saved, the new values become valid choices,
* and can be used in Choice and Choice List columns. Each choice in the TokenField
* also includes a color picker button to customize the fill/text color of the choice.
* The same capabilities of TokenField, such as undo/redo and rich copy/paste support,
* are present in ChoiceListEntry as well.
*
* When in view mode, it looks similar to edit mode, but hides the bottom input and the
* color picker dropdown buttons. Past 6 choices, it stops rendering individual choices
* and only shows the total number of additional choices that are hidden, and can be
* seen when edit mode is activated.
*
* Usage:
* > dom.create(ChoiceListEntry, values, options, (vals, options) => {});
*/
export class ChoiceListEntry extends Disposable {
private _isEditing: Observable<boolean> = Observable.create(this, false);
private _tokenFieldHolder: Holder<TokenField<ChoiceItem>> = Holder.create(this);
constructor(
private _values: Observable<string[]>,
private _choiceOptionsByName: Observable<ChoiceOptionsByName>,
private _onSave: (values: string[], choiceOptions: ChoiceOptionsByName) => void
) {
super();
// Since the saved values can be modified outside the ChoiceListEntry (via undo/redo),
// add a listener to update edit status on changes.
this.autoDispose(this._values.addListener(() => {
this._cancel();
}));
}
// Arg maxRows indicates the number of rows to display when the editor is inactive.
public buildDom(maxRows: number = 6): DomContents {
return dom.domComputed(this._isEditing, (editMode) => {
if (editMode) {
const tokenField = TokenField.ctor<ChoiceItem>().create(this._tokenFieldHolder, {
initialValue: this._values.get().map(label => {
return new ChoiceItem(label, this._choiceOptionsByName.get().get(label));
}),
renderToken: token => this._renderToken(token),
createToken: label => label.trim() !== '' ? new ChoiceItem(label.trim()) : undefined,
clipboardToTokens: clipboardToChoices,
tokensToClipboard: (tokens, clipboard) => {
// Save tokens as JSON for parts of the UI that support deserializing it properly (e.g. ChoiceListEntry).
clipboard.setData('application/json', JSON.stringify(tokens));
// Save token labels as newline-separated text, for general use (e.g. pasting into cells).
clipboard.setData('text/plain', tokens.map(t => t.label).join('\n'));
},
openAutocompleteOnFocus: false,
styles: {cssTokenField, cssToken, cssTokenInput, cssInputWrapper, cssDeleteButton, cssDeleteIcon},
keyBindings: {
previous: 'ArrowUp',
next: 'ArrowDown'
}
});
return cssVerticalFlex(
cssListBox(
elem => {
tokenField.attach(elem);
this._focusOnOpen(tokenField.getTextInput());
},
testId('choice-list-entry')
),
cssButtonRow(
primaryButton('Save',
dom.on('click', () => this._save() ),
testId('choice-list-entry-save')
),
basicButton('Cancel',
dom.on('click', () => this._cancel()),
testId('choice-list-entry-cancel')
)
),
dom.onKeyDown({Escape$: () => this._save()}),
);
} else {
const someValues = Computed.create(null, this._values, (_use, values) =>
values.length <= maxRows ? values : values.slice(0, maxRows - 1));
return cssVerticalFlex(
cssListBoxInactive(
dom.autoDispose(someValues),
dom.maybe(use => use(someValues).length === 0, () =>
row('No choices configured')
),
dom.domComputed(this._choiceOptionsByName, (choiceOptions) =>
dom.forEach(someValues, val => {
return row(
cssTokenColorInactive(
dom.style('background-color', getFillColor(choiceOptions.get(val))),
testId('choice-list-entry-color')
),
cssTokenLabel(val)
);
}),
),
// Show description row for any remaining rows
dom.maybe(use => use(this._values).length > maxRows, () =>
row(
dom.text((use) => `+${use(this._values).length - (maxRows - 1)} more`)
)
),
dom.on('click', () => this._startEditing()),
testId('choice-list-entry')
),
cssButtonRow(
primaryButton('Edit',
dom.on('click', () => this._startEditing()),
testId('choice-list-entry-edit')
)
)
);
}
});
}
private _startEditing(): void {
this._isEditing.set(true);
}
private _save(): void {
const tokenField = this._tokenFieldHolder.get();
if (!tokenField) { return; }
const tokens = tokenField.tokensObs.get();
const tokenInputVal = tokenField.getTextInput().value.trim();
if (tokenInputVal !== '') {
tokens.push(new ChoiceItem(tokenInputVal));
}
const newTokens = uniqBy(tokens, t => t.label);
const newValues = newTokens.map(t => t.label);
const newOptions: ChoiceOptionsByName = new Map();
for (const t of newTokens) {
if (t.options) {
newOptions.set(t.label, {
fillColor: t.options.fillColor,
textColor: t.options.textColor
});
}
}
// Call user save function if the values and/or options have changed.
if (!isEqual(this._values.get(), newValues)
|| !isEqual(this._choiceOptionsByName.get(), newOptions)) {
// Because of the listener on this._values, editing will stop if values are updated.
this._onSave(newValues, newOptions);
} else {
this._cancel();
}
}
private _cancel(): void {
this._isEditing.set(false);
}
private _focusOnOpen(elem: HTMLInputElement): void {
setTimeout(() => focus(elem), 0);
}
private _renderToken(token: ChoiceItem) {
const fillColorObs = Observable.create(null, getFillColor(token.options));
const textColorObs = Observable.create(null, getTextColor(token.options));
return cssColorAndLabel(
dom.autoDispose(fillColorObs),
dom.autoDispose(textColorObs),
colorButton(textColorObs,
fillColorObs,
async () => {
const tokenField = this._tokenFieldHolder.get();
if (!tokenField) { return; }
const fillColor = fillColorObs.get();
const textColor = textColorObs.get();
tokenField.replaceToken(token.label, new ChoiceItem(token.label, {fillColor, textColor}));
}
),
cssTokenLabel(token.label)
);
}
}
// Helper to focus on the token input and select/scroll to the bottom
function focus(elem: HTMLInputElement) {
elem.focus();
elem.setSelectionRange(elem.value.length, elem.value.length);
elem.scrollTo(0, elem.scrollHeight);
}
// Build a display row with the given DOM arguments
function row(...domArgs: DomElementArg[]): Element {
return cssListRow(
...domArgs,
testId('choice-list-entry-row')
);
}
function getTextColor(choiceOptions?: IChoiceOptions) {
return choiceOptions?.textColor ?? DEFAULT_TEXT_COLOR;
}
function getFillColor(choiceOptions?: IChoiceOptions) {
return choiceOptions?.fillColor ?? UNSET_COLOR;
}
/**
* Converts clipboard contents (if any) to choices.
*
* Attempts to convert from JSON first, if clipboard contains valid JSON.
* If conversion is not possible, falls back to converting from newline-separated plaintext.
*/
function clipboardToChoices(clipboard: DataTransfer): ChoiceItem[] {
const maybeTokens = clipboard.getData('application/json');
if (maybeTokens && isJSON(maybeTokens)) {
const tokens = JSON.parse(maybeTokens);
if (Array.isArray(tokens) && tokens.every((t): t is ChoiceItem => ChoiceItemChecker.test(t))) {
return tokens;
}
}
const maybeText = clipboard.getData('text/plain');
if (maybeText) {
return maybeText.split('\n').filter(t => t.trim() !== '').map(label => new ChoiceItem(label));
}
return [];
}
function isJSON(string: string) {
try {
JSON.parse(string);
return true;
} catch {
return false;
}
}
const cssListBox = styled('div', `
width: 100%;
padding: 1px;
line-height: 1.5;
padding-left: 4px;
padding-right: 4px;
border: 1px solid ${colors.hover};
border-radius: 4px;
background-color: white;
`);
const cssListBoxInactive = styled(cssListBox, `
cursor: pointer;
border: 1px solid ${colors.darkGrey};
&:hover {
border: 1px solid ${colors.hover};
}
`);
const cssListRow = styled('div', `
display: flex;
margin-top: 4px;
margin-bottom: 4px;
padding: 4px 8px;
color: ${colors.dark};
background-color: ${colors.mediumGrey};
border-radius: 3px;
overflow: hidden;
text-overflow: ellipsis;
`);
const cssTokenField = styled('div', `
&.token-dragactive {
cursor: grabbing;
}
`);
const cssToken = styled(cssListRow, `
position: relative;
display: flex;
justify-content: space-between;
user-select: none;
cursor: grab;
&.selected {
background-color: ${colors.darkGrey};
}
&.token-dragging {
pointer-events: none;
z-index: 1;
opacity: 0.7;
}
.${cssTokenField.className}.token-dragactive & {
cursor: unset;
}
`);
const cssTokenColorInactive = styled('div', `
flex-shrink: 0;
width: 18px;
height: 18px;
`);
const cssTokenLabel = styled('span', `
margin-left: 6px;
display: inline-block;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
`);
const cssTokenInput = styled('input', `
padding-top: 4px;
padding-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
flex: auto;
-webkit-appearance: none;
-moz-appearance: none;
border: none;
outline: none;
`);
const cssInputWrapper = styled('div', `
margin-top: 4px;
margin-bottom: 4px;
position: relative;
flex: auto;
display: flex;
`);
const cssFlex = styled('div', `
display: flex;
`);
const cssColorAndLabel = styled(cssFlex, `
max-width: calc(100% - 16px);
`);
const cssVerticalFlex = styled('div', `
width: 100%;
display: flex;
flex-direction: column;
`);
const cssButtonRow = styled('div', `
gap: 8px;
display: flex;
margin-top: 8px;
margin-bottom: 16px;
`);
const cssDeleteButton = styled('div', `
display: inline;
float:right;
cursor: pointer;
.${cssTokenField.className}.token-dragactive & {
cursor: unset;
}
`);
const cssDeleteIcon = styled(icon, `
--icon-color: ${colors.slate};
&:hover {
--icon-color: ${colors.dark};
}
`);

View File

@ -1,5 +1,5 @@
import * as commands from 'app/client/components/commands';
import {ListEntry} from 'app/client/lib/listEntry';
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';
@ -10,25 +10,61 @@ import {menu, menuItem} from 'app/client/ui2018/menus';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {Computed, dom, styled} from 'grainjs';
export interface IChoiceOptions {
textColor: string;
fillColor: string;
}
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;
}
export function getTextColor(choiceOptions?: IChoiceOptions) {
return choiceOptions?.textColor ?? DEFAULT_TEXT_COLOR;
}
/**
* ChoiceTextBox - A textbox for choice values.
*/
export class ChoiceTextBox extends NTextBox {
private _choices: KoSaveableObservable<string[]>;
private _choiceValues: Computed<string[]>;
private _choiceOptions: KoSaveableObservable<ChoiceOptions | null | undefined>;
private _choiceOptionsByName: Computed<ChoiceOptionsByName>
constructor(field: ViewFieldRec) {
super(field);
this._choices = this.options.prop('choices');
this._choiceOptions = this.options.prop('choiceOptions');
this._choiceValues = Computed.create(this, (use) => use(this._choices) || []);
this._choiceOptionsByName = Computed.create(this, (use) => toMap(use(this._choiceOptions)));
}
public buildDom(row: DataRowModel) {
const value = row.cells[this.field.colId()];
return cssChoiceField(
cssChoiceText(
dom.style('text-align', this.alignment),
dom.text((use) => use(row._isAddRow) ? '' : use(this.valueFormatter).format(use(value))),
cssChoiceTextWrapper(
dom.style('justify-content', (use) => use(this.alignment) === 'right' ? 'flex-end' : use(this.alignment)),
dom.domComputed((use) => {
if (use(row._isAddRow)) { return cssChoiceText(''); }
const formattedValue = use(this.valueFormatter).format(use(value));
if (formattedValue === '') { return cssChoiceText(''); }
const choiceOptions = use(this._choiceOptionsByName).get(formattedValue);
return cssChoiceText(
dom.style('background-color', getFillColor(choiceOptions)),
dom.style('color', getTextColor(choiceOptions)),
formattedValue,
testId('choice-text')
);
}),
),
this.buildDropdownMenu(),
);
@ -37,9 +73,20 @@ export class ChoiceTextBox extends NTextBox {
public buildConfigDom() {
return [
super.buildConfigDom(),
cssLabel('OPTIONS'),
cssLabel('CHOICES'),
cssRow(
dom.create(ListEntry, this._choiceValues, (values) => this._choices.saveOnly(values))
dom.create(
ChoiceListEntry,
this._choiceValues,
this._choiceOptionsByName,
(choices, choiceOptions) => {
return this.options.setAndSave({
...this.options.peek(),
choices,
choiceOptions: toObject(choiceOptions)
});
}
)
)
];
}
@ -52,6 +99,10 @@ export class ChoiceTextBox extends NTextBox {
return this._choiceValues;
}
protected getChoiceOptions(): Computed<ChoiceOptionsByName> {
return this._choiceOptionsByName;
}
protected buildDropdownMenu() {
return cssDropdownIcon('Dropdown',
// When choices exist, click dropdown icon to open edit autocomplete.
@ -74,14 +125,43 @@ export class ChoiceTextBox extends NTextBox {
}
}
// Converts a POJO containing choice options to an ES6 Map
function toMap(choiceOptions?: ChoiceOptions | null): ChoiceOptionsByName {
if (!choiceOptions) { return new Map(); }
return new Map(Object.entries(choiceOptions));
}
// Converts an ES6 Map containing choice options to a POJO
function toObject(choiceOptions: ChoiceOptionsByName): ChoiceOptions {
const object: ChoiceOptions = {};
for (const [choice, options] of choiceOptions.entries()) {
object[choice] = options;
}
return object;
}
const cssChoiceField = styled('div.field_clip', `
display: flex;
align-items: center;
padding: 0 3px;
`);
const cssChoiceTextWrapper = styled('div', `
display: flex;
width: 100%;
min-width: 0px;
overflow: hidden;
`);
const cssChoiceText = styled('div', `
width: 100%;
border-radius: 3px;
padding: 1px 4px;
margin: 2px;
overflow: hidden;
text-overflow: ellipsis;
height: min-content;
line-height: 16px;
`);
const cssDropdownIcon = styled(icon, `

View File

@ -227,7 +227,7 @@ const cssRefItem = styled('li', `
}
`);
const cssPlusButton = styled('div', `
export const cssPlusButton = styled('div', `
display: inline-block;
width: 20px;
height: 20px;
@ -242,7 +242,7 @@ const cssPlusButton = styled('div', `
}
`);
const cssPlusIcon = styled(icon, `
export const cssPlusIcon = styled(icon, `
background-color: ${colors.light};
`);

View File

@ -198,7 +198,8 @@ var typeDefs = {
icon: 'FieldTextbox',
options: {
alignment: 'left',
choices: null
choices: null,
choiceOptions: null
}
}
},
@ -214,7 +215,8 @@ var typeDefs = {
icon: 'FieldTextbox',
options: {
alignment: 'left',
choices: null
choices: null,
choiceOptions: null
}
}
},