mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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
This commit is contained in:
parent
6af811f7ab
commit
e7c4686536
68
app/client/ui2018/ColorPalette.ts
Normal file
68
app/client/ui2018/ColorPalette.ts
Normal file
@ -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"
|
||||||
|
];
|
321
app/client/ui2018/ColorSelect.ts
Normal file
321
app/client/ui2018/ColorSelect.ts
Normal file
@ -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<string>, fillColor: Observable<string>,
|
||||||
|
onSave: () => Promise<void>): 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<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(); },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
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;
|
||||||
|
`);
|
Loading…
Reference in New Issue
Block a user