(core) Adding font options to the style picker

Summary:
Redesigning color picker:
- Single color palette (no light/dark switch)
- Ability to remove color (new empty button)

New font options in the color picker.
Font options are available on:
- Default cell style
- Conditional rules styles
- Choice/ChoiceList editor and token field
- Filters for Choice/ChoiceList columns

Design document:
https://www.figma.com/file/bRTsb47VIOVBfJPj0qF3C9/Grist-Updates?node-id=415%3A8135

Test Plan: new and updated tests

Reviewers: georgegevoian, alexmojaki

Reviewed By: georgegevoian, alexmojaki

Subscribers: alexmojaki

Differential Revision: https://phab.getgrist.com/D3335
This commit is contained in:
Jarosław Sadziński
2022-04-07 16:58:16 +02:00
parent 98ac2f7e5b
commit 34708cd348
28 changed files with 711 additions and 270 deletions

View File

@@ -14,6 +14,7 @@ function AbstractWidget(field, opts = {}) {
const {defaultTextColor = '#000000'} = opts;
this.defaultTextColor = defaultTextColor;
this.valueFormatter = this.field.visibleColFormatter;
this.defaultTextColor = opts.defaultTextColor || '#000000';
}
dispose.makeDisposable(AbstractWidget);

View File

@@ -6,7 +6,7 @@ import {Style} from 'app/client/models/Styles';
import {cssFieldFormula} from 'app/client/ui/FieldConfig';
import {cssIcon, cssLabel, cssRow} from 'app/client/ui/RightPanel';
import {textButton} from 'app/client/ui2018/buttons';
import {colorSelect} from 'app/client/ui2018/ColorSelect';
import {ColorOption, colorSelect} from 'app/client/ui2018/ColorSelect';
import {colors} from 'app/client/ui2018/cssVars';
import {setupEditorCleanup} from 'app/client/widgets/FieldEditor';
import {cssError, openFormulaEditor} from 'app/client/widgets/FormulaEditor';
@@ -18,8 +18,12 @@ import debounce = require('lodash/debounce');
const testId = makeTestId('test-widget-style-');
export class CellStyle extends Disposable {
protected textColor: Observable<string>;
protected fillColor: Observable<string>;
protected textColor: Observable<string|undefined>;
protected fillColor: Observable<string|undefined>;
protected fontBold: Observable<boolean|undefined>;
protected fontUnderline: Observable<boolean|undefined>;
protected fontItalic: Observable<boolean|undefined>;
protected fontStrikethrough: Observable<boolean|undefined>;
// Holds data from currently selected record (holds data only when this field has conditional styles).
protected currentRecord: Computed<RowRecord | undefined>;
// Helper field for refreshing current record data.
@@ -28,14 +32,15 @@ export class CellStyle extends Disposable {
constructor(
protected field: ViewFieldRec,
protected gristDoc: GristDoc,
defaultTextColor: string = '#000000'
protected defaultTextColor: string
) {
super();
this.textColor = Computed.create(
this,
use => use(this.field.textColor) || defaultTextColor
).onWrite(val => this.field.textColor(val === defaultTextColor ? '' : val));
this.textColor = fromKo(this.field.textColor);
this.fillColor = fromKo(this.field.fillColor);
this.fontBold = fromKo(this.field.fontBold);
this.fontUnderline = fromKo(this.field.fontUnderline);
this.fontItalic = fromKo(this.field.fontItalic);
this.fontStrikethrough = fromKo(this.field.fontStrikethrough);
this.currentRecord = Computed.create(this, use => {
if (!use(this.field.hasRules)) {
return;
@@ -75,8 +80,14 @@ export class CellStyle extends Disposable {
cssLabel('CELL STYLE', dom.autoDispose(holder)),
cssRow(
colorSelect(
this.textColor,
this.fillColor,
{
textColor: new ColorOption(this.textColor, false, this.defaultTextColor),
fillColor: new ColorOption(this.fillColor, true, '', 'none', '#FFFFFF'),
fontBold: this.fontBold,
fontItalic: this.fontItalic,
fontUnderline: this.fontUnderline,
fontStrikethrough: this.fontStrikethrough
},
// Calling `field.widgetOptionsJson.save()` saves both fill and text color settings.
() => this.field.widgetOptionsJson.save()
)
@@ -98,6 +109,10 @@ export class CellStyle extends Disposable {
...rules.map((column, ruleIndex) => {
const textColor = this._buildStyleOption(owner, ruleIndex, 'textColor');
const fillColor = this._buildStyleOption(owner, ruleIndex, 'fillColor');
const fontBold = this._buildStyleOption(owner, ruleIndex, 'fontBold');
const fontItalic = this._buildStyleOption(owner, ruleIndex, 'fontItalic');
const fontUnderline = this._buildStyleOption(owner, ruleIndex, 'fontUnderline');
const fontStrikethrough = this._buildStyleOption(owner, ruleIndex, 'fontStrikethrough');
const save = async () => {
// This will save both options.
await this.field.rulesStyles.save();
@@ -131,7 +146,18 @@ export class CellStyle extends Disposable {
dom.show(hasError),
testId(`rule-error-${ruleIndex}`),
),
colorSelect(textColor, fillColor, save, true)
colorSelect(
{
textColor: new ColorOption(textColor, true, '', 'default'),
fillColor: new ColorOption(fillColor, true, '', 'none'),
fontBold,
fontItalic,
fontUnderline,
fontStrikethrough
},
save,
'Cell style'
)
),
cssRemoveButton(
'Remove',
@@ -152,12 +178,12 @@ export class CellStyle extends Disposable {
];
}
private _buildStyleOption(owner: Disposable, index: number, option: keyof Style) {
private _buildStyleOption<T extends keyof Style>(owner: Disposable, index: number, option: T) {
const obs = Computed.create(owner, use => use(this.field.rulesStyles)[index]?.[option]);
obs.onWrite(value => {
const list = Array.from(this.field.rulesStyles.peek() ?? []);
list[index] = list[index] ?? {};
list[index][option] = value;
list[index][option] = value as any;
this.field.rulesStyles(list);
});
return obs;

View File

@@ -12,7 +12,7 @@ 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 {ChoiceOptions, getRenderFillColor, getRenderTextColor} from 'app/client/widgets/ChoiceTextBox';
import {choiceToken, cssChoiceACItem} from 'app/client/widgets/ChoiceToken';
import {icon} from 'app/client/ui2018/icons';
@@ -73,8 +73,12 @@ export class ChoiceListEditor extends NewBaseEditor {
initialValue: startTokens,
renderToken: item => [
item.label,
dom.style('background-color', getFillColor(this._choiceOptionsByName[item.label])),
dom.style('color', getTextColor(this._choiceOptionsByName[item.label])),
dom.style('background-color', getRenderFillColor(this._choiceOptionsByName[item.label])),
dom.style('color', getRenderTextColor(this._choiceOptionsByName[item.label])),
dom.cls('font-bold', this._choiceOptionsByName[item.label]?.fontBold ?? false),
dom.cls('font-underline', this._choiceOptionsByName[item.label]?.fontUnderline ?? false),
dom.cls('font-italic', this._choiceOptionsByName[item.label]?.fontItalic ?? false),
dom.cls('font-strikethrough', this._choiceOptionsByName[item.label]?.fontStrikethrough ?? false),
cssInvalidToken.cls('-invalid', item.isInvalid)
],
createToken: label => new ChoiceItem(label, !choiceSet.has(label)),

View File

@@ -1,14 +1,13 @@
import {IToken, TokenField} from 'app/client/lib/TokenField';
import {cssBlockedCursor} from 'app/client/ui/RightPanel';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {colorButton} from 'app/client/ui2018/ColorSelect';
import {colorButton, ColorOption} from 'app/client/ui2018/ColorSelect';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {editableLabel} from 'app/client/ui2018/editableLabel';
import {icon} from 'app/client/ui2018/icons';
import {ChoiceOptionsByName, IChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
import {DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken';
import {Computed, Disposable, dom, DomContents, DomElementArg, Holder, Observable, styled} from 'grainjs';
import {createCheckers, iface, ITypeSuite, opt} from 'ts-interface-checker';
import {createCheckers, iface, ITypeSuite, opt, union} from 'ts-interface-checker';
import isEqual = require('lodash/isEqual');
import uniqBy = require('lodash/uniqBy');
@@ -33,7 +32,7 @@ class ChoiceItem implements IToken {
public label: string,
// We will keep the previous label value for a token, to tell us which token
// was renamed. For new tokens this should be null.
public readonly previousLabel: string | null,
public previousLabel: string | null,
public options?: IChoiceOptions
) {}
@@ -41,19 +40,24 @@ class ChoiceItem implements IToken {
return new ChoiceItem(label, this.previousLabel, this.options);
}
public changeColors(options: IChoiceOptions) {
public changeStyle(options: IChoiceOptions) {
return new ChoiceItem(this.label, this.previousLabel, {...this.options, ...options});
}
}
const ChoiceItemType = iface([], {
label: "string",
previousLabel: union("string", "null"),
options: opt("ChoiceOptionsType"),
});
const ChoiceOptionsType = iface([], {
textColor: "string",
fillColor: "string",
textColor: opt("string"),
fillColor: opt("string"),
fontBold: opt("boolean"),
fontUnderline: opt("boolean"),
fontItalic: opt("boolean"),
fontStrikethrough: opt("boolean"),
});
const choiceTypes: ITypeSuite = {
@@ -63,8 +67,6 @@ const choiceTypes: ITypeSuite = {
const {ChoiceItemType: ChoiceItemChecker} = createCheckers(choiceTypes);
const UNSET_COLOR = '#ffffff';
/**
* ChoiceListEntry - Editor for choices and choice colors.
*
@@ -166,17 +168,29 @@ export class ChoiceListEntry extends Disposable {
dom.forEach(someValues, val => {
return row(
cssTokenColorInactive(
dom.style('background-color', getFillColor(choiceOptions.get(val))),
dom.style('background-color', getFillColor(choiceOptions.get(val)) || '#FFFFFF'),
dom.style('color', getTextColor(choiceOptions.get(val)) || '#000000'),
dom.cls('font-bold', choiceOptions.get(val)?.fontBold ?? false),
dom.cls('font-underline', choiceOptions.get(val)?.fontUnderline ?? false),
dom.cls('font-italic', choiceOptions.get(val)?.fontItalic ?? false),
dom.cls('font-strikethrough', choiceOptions.get(val)?.fontStrikethrough ?? false),
'T',
testId('choice-list-entry-color')
),
cssTokenLabel(val)
cssTokenLabel(
val,
testId('choice-list-entry-label')
)
);
}),
),
// 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('span',
testId('choice-list-entry-label'),
dom.text((use) => `+${use(this._values).length - (maxRows - 1)} more`)
)
)
),
dom.on('click', () => this._startEditing()),
@@ -215,12 +229,15 @@ export class ChoiceListEntry extends Disposable {
const newTokens = uniqBy(tokens, t => t.label);
const newValues = newTokens.map(t => t.label);
const newOptions: ChoiceOptionsByName = new Map();
const keys: Array<keyof IChoiceOptions> = [
'fillColor', 'textColor', 'fontBold', 'fontItalic', 'fontStrikethrough', 'fontUnderline'
];
for (const t of newTokens) {
if (t.options) {
newOptions.set(t.label, {
fillColor: t.options.fillColor,
textColor: t.options.textColor
});
const options: IChoiceOptions = {};
keys.filter(k => t.options![k] !== undefined)
.forEach(k => options[k] = t.options![k] as any);
newOptions.set(t.label, options);
}
}
@@ -245,6 +262,10 @@ export class ChoiceListEntry extends Disposable {
private _renderToken(token: ChoiceItem) {
const fillColorObs = Observable.create(null, getFillColor(token.options));
const textColorObs = Observable.create(null, getTextColor(token.options));
const fontBoldObs = Observable.create(null, token.options?.fontBold);
const fontItalicObs = Observable.create(null, token.options?.fontItalic);
const fontUnderlineObs = Observable.create(null, token.options?.fontUnderline);
const fontStrikethroughObs = Observable.create(null, token.options?.fontStrikethrough);
const choiceText = Observable.create(null, token.label);
const rename = async (to: string) => {
@@ -275,15 +296,32 @@ export class ChoiceListEntry extends Disposable {
dom.autoDispose(fillColorObs),
dom.autoDispose(textColorObs),
dom.autoDispose(choiceText),
colorButton(textColorObs,
fillColorObs,
colorButton({
textColor: new ColorOption(textColorObs, false, '#000000'),
fillColor: new ColorOption(fillColorObs, true, '', 'none', '#FFFFFF'),
fontBold: fontBoldObs,
fontItalic: fontItalicObs,
fontUnderline: fontUnderlineObs,
fontStrikethrough: fontStrikethroughObs
},
async () => {
const tokenField = this._tokenFieldHolder.get();
if (!tokenField) { return; }
const fillColor = fillColorObs.get();
const textColor = textColorObs.get();
tokenField.replaceToken(token.label, ChoiceItem.from(token).changeColors({fillColor, textColor}));
const fontBold = fontBoldObs.get();
const fontItalic = fontItalicObs.get();
const fontUnderline = fontUnderlineObs.get();
const fontStrikethrough = fontStrikethroughObs.get();
tokenField.replaceToken(token.label, ChoiceItem.from(token).changeStyle({
fillColor,
textColor,
fontBold,
fontItalic,
fontUnderline,
fontStrikethrough,
}));
}
),
editableLabel(choiceText,
@@ -320,11 +358,11 @@ function row(...domArgs: DomElementArg[]): Element {
}
function getTextColor(choiceOptions?: IChoiceOptions) {
return choiceOptions?.textColor ?? DEFAULT_TEXT_COLOR;
return choiceOptions?.textColor;
}
function getFillColor(choiceOptions?: IChoiceOptions) {
return choiceOptions?.fillColor ?? UNSET_COLOR;
return choiceOptions?.fillColor;
}
/**
@@ -336,8 +374,9 @@ function getFillColor(choiceOptions?: IChoiceOptions) {
function clipboardToChoices(clipboard: DataTransfer): ChoiceItem[] {
const maybeTokens = clipboard.getData('application/json');
if (maybeTokens && isJSON(maybeTokens)) {
const tokens = JSON.parse(maybeTokens);
const tokens: ChoiceItem[] = JSON.parse(maybeTokens);
if (Array.isArray(tokens) && tokens.every((t): t is ChoiceItem => ChoiceItemChecker.test(t))) {
tokens.forEach(t => t.previousLabel = null);
return tokens;
}
}
@@ -424,6 +463,8 @@ const cssTokenColorInactive = styled('div', `
flex-shrink: 0;
width: 18px;
height: 18px;
display: grid;
place-items: center;
`);
const cssTokenLabel = styled('span', `

View File

@@ -1,26 +1,23 @@
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 {Style} from 'app/client/models/Styles';
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
import {testId} from 'app/client/ui2018/cssVars';
import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry';
import {choiceToken, DEFAULT_FILL_COLOR, DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {Computed, dom, fromKo, styled} from 'grainjs';
import {choiceToken, DEFAULT_FILL_COLOR, DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken';
export interface IChoiceOptions {
textColor: string;
fillColor: string;
}
export type IChoiceOptions = Style
export type ChoiceOptions = Record<string, IChoiceOptions | undefined>;
export type ChoiceOptionsByName = Map<string, IChoiceOptions | undefined>;
export function getFillColor(choiceOptions?: IChoiceOptions) {
export function getRenderFillColor(choiceOptions?: IChoiceOptions) {
return choiceOptions?.fillColor ?? DEFAULT_FILL_COLOR;
}
export function getTextColor(choiceOptions?: IChoiceOptions) {
export function getRenderTextColor(choiceOptions?: IChoiceOptions) {
return choiceOptions?.textColor ?? DEFAULT_TEXT_COLOR;
}

View File

@@ -1,13 +1,11 @@
import {dom, DomContents, DomElementArg, styled} from "grainjs";
import {colors, vars} from "app/client/ui2018/cssVars";
import {Style} from 'app/client/models/Styles';
export const DEFAULT_FILL_COLOR = colors.mediumGreyOpaque.value;
export const DEFAULT_TEXT_COLOR = '#000000';
export interface IChoiceTokenOptions {
fillColor?: string;
textColor?: string;
}
export type IChoiceTokenOptions = Style;
/**
* Creates a colored token representing a choice (e.g. Choice and Choice List values).
@@ -25,13 +23,17 @@ export interface IChoiceTokenOptions {
*/
export function choiceToken(
label: DomElementArg,
{fillColor, textColor}: IChoiceTokenOptions,
{fillColor, textColor, fontBold, fontItalic, fontUnderline, fontStrikethrough}: IChoiceTokenOptions,
...args: DomElementArg[]
): DomContents {
return cssChoiceToken(
label,
dom.style('background-color', fillColor ?? DEFAULT_FILL_COLOR),
dom.style('color', textColor ?? DEFAULT_TEXT_COLOR),
dom.cls('font-bold', fontBold ?? false),
dom.cls('font-underline', fontUnderline ?? false),
dom.cls('font-italic', fontItalic ?? false),
dom.cls('font-strikethrough', fontStrikethrough ?? false),
...args
);
}

View File

@@ -54,6 +54,22 @@ function getTypeDefinition(type: string | false) {
return UserType.typeDefs[type] || UserType.typeDefs.Text;
}
type ComputedStyle = {style?: Style; error?: true} | null | undefined;
/**
* Builds a font option computed property.
*/
function buildFontOptions(
builder: FieldBuilder,
computedRule: ko.Computed<ComputedStyle>,
optionName: keyof Style) {
return koUtil.withKoUtils(ko.computed(function() {
if (builder.isDisposed()) { return false; }
const style = computedRule()?.style;
const styleFlag = style?.[optionName] || this.field[optionName]();
return styleFlag;
}, builder)).onlyNotifyUnequal();
}
/**
* Creates an instance of FieldBuilder. Used to create all column configuration DOMs, cell DOMs,
* and cell editor DOMs for all Grist Types.
@@ -427,7 +443,7 @@ export class FieldBuilder extends Disposable {
// rules there is a brief moment that rule is still not evaluated
// (rules.length != value.length), in this case return last value
// and wait for the update.
const computedRule = koUtil.withKoUtils(ko.pureComputed(() => {
const computedRule = koUtil.withKoUtils(ko.pureComputed<ComputedStyle>(() => {
if (this.isDisposed()) { return null; }
const styles: Style[] = this.field.rulesStyles();
// Make sure that rules where computed.
@@ -469,12 +485,21 @@ export class FieldBuilder extends Disposable {
return fromRules || this.field.textColor() || '';
}, this)).onlyNotifyUnequal();
const background = koUtil.withKoUtils(ko.computed(function() {
const fillColor = koUtil.withKoUtils(ko.computed(function() {
if (this.isDisposed()) { return null; }
const fromRules = computedRule()?.style?.fillColor;
return fromRules || this.field.fillColor();
let fill = fromRules || this.field.fillColor();
// If user set white color - remove it to play nice with zebra strips.
// If there is no color we are using fully transparent white color (for tests mainly).
fill = fill ? fill.toUpperCase() : fill;
return (fill === '#FFFFFF' ? '' : fill) || '#FFFFFF00';
}, this)).onlyNotifyUnequal();
const fontBold = buildFontOptions(this, computedRule, 'fontBold');
const fontItalic = buildFontOptions(this, computedRule, 'fontItalic');
const fontUnderline = buildFontOptions(this, computedRule, 'fontUnderline');
const fontStrikethrough = buildFontOptions(this, computedRule, 'fontStrikethrough');
const errorInStyle = ko.pureComputed(() => Boolean(computedRule()?.error));
return (elem: Element) => {
@@ -485,7 +510,11 @@ export class FieldBuilder extends Disposable {
dom.autoDispose(errorInStyle),
dom.autoDispose(textColor),
dom.autoDispose(computedRule),
dom.autoDispose(background),
dom.autoDispose(fillColor),
dom.autoDispose(fontBold),
dom.autoDispose(fontItalic),
dom.autoDispose(fontUnderline),
dom.autoDispose(fontStrikethrough),
this._options.isPreview ? null : kd.cssClass(this.field.formulaCssClass),
kd.toggleClass("readonly", toKo(ko, this._readonly)),
kd.maybe(isSelected, () => dom('div.selected_cursor',
@@ -496,8 +525,12 @@ export class FieldBuilder extends Disposable {
const cellDom = widget ? widget.buildDom(row) : buildErrorDom(row, this.field);
return dom(cellDom, kd.toggleClass('has_cursor', isActive),
kd.toggleClass('field-error-from-style', errorInStyle),
kd.toggleClass('font-bold', fontBold),
kd.toggleClass('font-underline', fontUnderline),
kd.toggleClass('font-italic', fontItalic),
kd.toggleClass('font-strikethrough', fontStrikethrough),
kd.style('--grist-cell-color', textColor),
kd.style('--grist-cell-background-color', background));
kd.style('--grist-cell-background-color', fillColor));
})
);
};

View File

@@ -42,10 +42,9 @@ export abstract class NewAbstractWidget extends Disposable {
constructor(protected field: ViewFieldRec, opts: Options = {}) {
super();
const {defaultTextColor = '#000000'} = opts;
this.defaultTextColor = defaultTextColor;
this.options = field.widgetOptionsJson;
this.valueFormatter = fromKo(field.formatter);
this.defaultTextColor = opts?.defaultTextColor || '#000000';
}
/**