gristlabs_grist-core/app/client/ui2018/ColorSelect.ts
George Gevoian 2f900f68f8 (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
2021-07-09 12:07:38 -07:00

350 lines
9.9 KiB
TypeScript

import { darker, lighter } from "app/client/ui2018/ColorPalette";
import { colors, testId, vars } from 'app/client/ui2018/cssVars';
import { textInput } from "app/client/ui2018/editableLabel";
import { icon } from "app/client/ui2018/icons";
import { isValidHex } from "app/common/gutil";
import { cssSelectBtn } from 'app/client/ui2018/select';
import { Computed, Disposable, dom, DomArg, Observable, onKeyDown, styled } from "grainjs";
import { defaultMenuOptions, IOpenController, setPopupToCreateDom } from "popweasel";
/**
* colorSelect allows to select color for both fill and text cell color. It allows for fast
* selection thanks to two color palette, `lighter` and `darker`, and also to pick custom color with
* native color picker. Pressing Escape reverts to the saved value. Caller is expected to handle
* logging of onSave() callback rejection. In case of rejection, values are reverted to their saved one.
*/
export function colorSelect(textColor: Observable<string>, fillColor: Observable<string>,
onSave: () => Promise<void>): Element {
const selectBtn = cssSelectBtn(
cssContent(
cssButtonIcon(
'T',
dom.style('color', textColor),
dom.style('background-color', (use) => use(fillColor).slice(0, 7)),
cssLightBorder.cls(''),
testId('btn-icon'),
),
'Cell Color',
),
icon('Dropdown'),
testId('color-select'),
);
const domCreator = (ctl: IOpenController) => buildColorPicker(ctl, textColor, fillColor, onSave);
setPopupToCreateDom(selectBtn, domCreator, {...defaultMenuOptions, placement: 'bottom-end'});
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);
const fillColorModel = PickerModel.create(null, fillColor);
function revert() {
textColorModel.revert();
fillColorModel.revert();
ctl.close();
}
ctl.onDispose(async () => {
if (textColorModel.needsSaving() || fillColorModel.needsSaving()) {
try {
// TODO: disable the trigger btn while saving
await onSave();
} catch (e) {
/* Does no logging: onSave() callback is expected to handle their reporting */
textColorModel.revert();
fillColorModel.revert();
}
}
textColorModel.dispose();
fillColorModel.dispose();
});
const colorSquare = (...args: DomArg[]) => cssColorSquare(
...args,
dom.style('color', textColor),
dom.style('background-color', fillColor),
cssLightBorder.cls(''),
);
return cssContainer(
dom.create(PickerComponent, fillColorModel, {
colorSquare: colorSquare(),
title: 'fill',
defaultMode: 'lighter'
}),
cssVSpacer(),
dom.create(PickerComponent, textColorModel, {
colorSquare: colorSquare('T'),
title: 'text',
defaultMode: 'darker'
}),
// gives focus and binds keydown events
(elem: any) => { setTimeout(() => elem.focus(), 0); },
onKeyDown({
Escape: () => { revert(); },
Enter: () => { ctl.close(); },
}),
// Set focus when `focusout` is bubbling from a children element. This is to allow to receive
// keyboard event again after user interacted with the hex box text input.
dom.on('focusout', (ev, elem) => (ev.target !== elem) && elem.focus()),
);
}
interface PickerComponentOptions {
colorSquare: Element;
title: string;
defaultMode: 'darker'|'lighter';
}
// PickerModel is a helper model that helps keep track of the server value for an observable that
// needs to be changed locally without saving. To use, you must call `model.setValue(...)` instead
// of `obs.set(...)`. Then it offers `model.needsSaving()` that tells you whether current value
// needs saving, and `model.revert()` that reverts obs to the its server value.
class PickerModel extends Disposable {
private _serverValue = this.obs.get();
private _localChange: boolean = false;
constructor(public obs: Observable<string>) {
super();
this.autoDispose(this.obs.addListener((val) => {
if (this._localChange) { return; }
this._serverValue = val;
}));
}
// Set the value picked by the user
public setValue(val: string) {
this._localChange = true;
this.obs.set(val);
this._localChange = false;
}
// Revert obs to its server value
public revert() {
this.obs.set(this._serverValue);
}
// Is current value different from the server value?
public needsSaving() {
return this.obs.get() !== this._serverValue;
}
}
class PickerComponent extends Disposable {
private _color = Computed.create(this, this._model.obs, (use, val) => val.toUpperCase().slice(0, 7));
private _mode = Observable.create<'darker'|'lighter'>(this, this._guessMode());
constructor(private _model: PickerModel, private _options: PickerComponentOptions) {
super();
}
public buildDom() {
const title = this._options.title;
return [
cssHeaderRow(
cssColorPreview(
dom.update(
this._options.colorSquare,
cssColorInput(
{type: 'color'},
dom.attr('value', this._color),
dom.on('input', (ev, elem) => this._setValue(elem.value)),
testId(`${title}-input`),
),
),
cssHexBox(
this._color,
async (val) => { if (isValidHex(val)) { this._model.setValue(val); } },
testId(`${title}-hex`),
// select the hex value on click. Doing it using settimeout allows to avoid some
// sporadically losing the selection just after the click.
dom.on('click', (ev, elem) => setTimeout(() => elem.select(), 0)),
)
),
title,
cssBrickToggle(
cssBrick(
cssBrick.cls('-selected', (use) => use(this._mode) === 'darker'),
dom.on('click', () => this._mode.set('darker')),
testId(`${title}-darker-brick`),
),
cssBrick(
cssBrick.cls('-lighter'),
cssBrick.cls('-selected', (use) => use(this._mode) === 'lighter'),
dom.on('click', () => this._mode.set('lighter')),
testId(`${title}-lighter-brick`),
),
),
),
cssPalette(
dom.domComputed(this._mode, (mode) => (mode === 'lighter' ? lighter : darker).map(color => (
cssColorSquare(
dom.style('background-color', color),
cssLightBorder.cls('', (use) => use(this._mode) === 'lighter'),
cssColorSquare.cls('-selected', (use) => use(this._color) === color),
dom.style('outline-color', (use) => use(this._mode) === 'lighter' ? '' : color),
dom.on('click', () => this._setValue(color)),
testId(`color-${color}`),
)
))),
testId(`${title}-palette`),
),
];
}
private _setValue(val: string) {
this._model.setValue(val);
}
private _guessMode(): 'darker'|'lighter' {
if (lighter.indexOf(this._color.get()) > -1) {
return 'lighter';
}
if (darker.indexOf(this._color.get()) > -1) {
return 'darker';
}
return this._options.defaultMode;
}
}
const cssColorInput = styled('input', `
opacity: 0;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 0;
border: none;
`);
const cssBrickToggle = styled('div', `
display: flex;
`);
const cssBrick = styled('div', `
height: 20px;
width: 34px;
background-color: #414141;
box-shadow: inset 0 0 0 2px #FFFFFF;
&-selected {
border: 1px solid #414141;
box-shadow: inset 0 0 0 1px #FFFFFF;
}
&-lighter {
background-color: #DCDCDC;
}
`);
const cssColorPreview = styled('div', `
display: flex;
`);
const cssHeaderRow = styled('div', `
display: flex;
justify-content: space-between;
margin-bottom: 8px;
text-transform: capitalize;
`);
const cssPalette = styled('div', `
width: 236px;
height: 68px;
display: flex;
flex-direction: column;
flex-wrap: wrap;
justify-content: space-between;
align-content: space-between;
`);
const cssVSpacer = styled('div', `
height: 12px;
`);
const cssContainer = styled('div', `
padding: 18px 16px;
background-color: white;
box-shadow: 0 2px 16px 0 rgba(38,38,51,0.6);
z-index: 20;
margin: 2px 0;
&:focus {
outline: none;
}
`);
const cssContent = styled('div', `
display: flex;
align-items: center;
`);
const cssHexBox = styled(textInput, `
border: 1px solid #D9D9D9;
border-left: none;
font-size: ${vars.smallFontSize};
display: flex;
align-items: center;
color: ${colors.slate};
width: 56px;
outline: none;
padding: 0 3px;
height: unset;
border-radius: unset;
`);
const cssLightBorder = styled('div', `
border: 1px solid #D9D9D9;
`);
const cssColorSquare = styled('div', `
width: 20px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
&-selected {
outline: 1px solid #D9D9D9;
outline-offset: 1px;
}
`);
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;
`);