From e7c468653661035777423a46421682fc17642ec2 Mon Sep 17 00:00:00 2001 From: Cyprien P Date: Fri, 19 Feb 2021 11:11:00 +0100 Subject: [PATCH] (core) Brings the new color select. Summary: Component is implemented as a grainjs ui component and can be tested using `yarn serve-projects`. This diff does not bring color select to Grist just yet. Follow up: - Make it possible to set a custom color by typing hex value directly in. - Disable the button while save call is pending. Test Plan: - Adds a project test Reviewers: paulfitz Reviewed By: paulfitz Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D2733 --- app/client/ui2018/ColorPalette.ts | 68 +++++++ app/client/ui2018/ColorSelect.ts | 321 ++++++++++++++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 app/client/ui2018/ColorPalette.ts create mode 100644 app/client/ui2018/ColorSelect.ts diff --git a/app/client/ui2018/ColorPalette.ts b/app/client/ui2018/ColorPalette.ts new file mode 100644 index 00000000..eb869083 --- /dev/null +++ b/app/client/ui2018/ColorPalette.ts @@ -0,0 +1,68 @@ +/* + * The palettes were inspired by comparisons of a handful of popular services. + */ +export const lighter = [ + "#FFFFFF", + "#DCDCDC", + "#B4B4B4", + "#FECBCC", + "#FD8182", + "#FC363B", + "#F3E1D2", + "#D6A77F", + "#C37739", + "#FEE7C3", + "#FECC81", + "#FDA630", + "#FFFACD", + "#FEF47A", + "#FEEB36", + "#E1FEDE", + "#98FD90", + "#35FD31", + "#CCFEFE", + "#8AFCFE", + "#2EF8FE", + "#D3E7FE", + "#75B5FC", + "#2486FB", + "#E8D0FE", + "#BC77FC", + "#9633FB", + "#FED6FB", + "#FD79F4", + "#FC2AED" +]; + +export const darker = [ + "#888888", + "#414141", + "#000000", + "#E00A17", + "#B60610", + "#740206", + "#AA632B", + "#824617", + "#653008", + "#FD9D28", + "#E38D22", + "#B36F19", + "#E8D62F", + "#C0B225", + "#928619", + "#2AE028", + "#1FAA1C", + "#126E0E", + "#24D6DB", + "#189DA1", + "#0C686A", + "#157AFB", + "#0F64CF", + "#084794", + "#8725FB", + "#6318B8", + "#460D81", + "#E621D7", + "#B818AC", + "#760C6E" +]; diff --git a/app/client/ui2018/ColorSelect.ts b/app/client/ui2018/ColorSelect.ts new file mode 100644 index 00000000..15d2614e --- /dev/null +++ b/app/client/ui2018/ColorSelect.ts @@ -0,0 +1,321 @@ +import { darker, lighter } from "app/client/ui2018/ColorPalette"; +import { colors, testId, vars } from 'app/client/ui2018/cssVars'; +import { icon } from "app/client/ui2018/icons"; +import { Computed, Disposable, dom, DomArg, Observable, onKeyDown, styled } from "grainjs"; +import { 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, fillColor: Observable, + onSave: () => Promise): Element { + const selectBtn = cssSelectBtn( + cssContent( + cssButtonIcon( + 'T', + dom.style('color', textColor), + dom.style('background-color', fillColor), + cssLightBorder.cls(''), + testId('btn-icon'), + ), + 'Cell Color', + ), + icon('Dropdown'), + testId('color-select'), + ); + + const domCreator = (ctl: IOpenController) => buildColorPicker(ctl, textColor, fillColor, onSave); + setPopupToCreateDom(selectBtn, domCreator, { + trigger: ['click'], + placement: 'bottom-end', + }); + + return selectBtn; +} + +function buildColorPicker(ctl: IOpenController, textColor: Observable, fillColor: Observable, + onSave: () => Promise): 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(); }, + }) + ); +} + +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) { + 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()); + 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`), + ), + ), + // TODO: make it possible to type in hex value. + cssHexBox( + dom.attr('value', (use) => use(this._color || '#000000')), + {readonly: true}, + dom.on('click', (ev, e) => e.select()), + testId(`${title}-hex`), + ) + ), + 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; +`); + + +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); + &:focus { + outline: none; + } +`); + +const cssContent = styled('div', ` + display: flex; + align-items: center; +`); + +const cssHexBox = styled('input', ` + 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; +`); + +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; +`); + +const cssSelectBtn = styled('div', ` + display: flex; + width: 100%; + height: 30px; + justify-content: space-between; + border-radius: 3px; + border: 1px solid #D9D9D9; + padding: 5px 9px; + user-select: none; + cursor: pointer; +`);