mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +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