@ -1,11 +1,39 @@
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" ;
import { basicButton , primaryButton } from 'app/client/ui2018/buttons' ;
import { isLight , swatches } from 'app/client/ui2018/ColorPalette' ;
import { colors , testId , vars } from 'app/client/ui2018/cssVars' ;
import { textInput } from 'app/client/ui2018/editableLabel' ;
import { IconName } from 'app/client/ui2018/IconList' ;
import { icon } from 'app/client/ui2018/icons' ;
import { cssSelectBtn } from 'app/client/ui2018/select' ;
import { isValidHex } from 'app/common/gutil' ;
import { Computed , Disposable , dom , Observable , onKeyDown , styled } from 'grainjs' ;
import { defaultMenuOptions , IOpenController , setPopupToCreateDom } from 'popweasel' ;
export interface StyleOptions {
textColor : ColorOption ,
fillColor : ColorOption ,
fontBold : Observable < boolean | undefined > ,
fontUnderline : Observable < boolean | undefined > ,
fontItalic : Observable < boolean | undefined > ,
fontStrikethrough : Observable < boolean | undefined > ,
}
export class ColorOption {
constructor (
public color : Observable < string | undefined > ,
// If the color accepts undefined/empty as a value. Controls empty selector in the picker.
public allowsNone : boolean = false ,
// Default color to show when value is empty or undefined (itself can be empty).
public defaultColor : string = '' ,
// Text to be shown in the picker when color is not set.
public noneText : string = '' ,
// Preview color to show when value is undefined.
public previewNoneColor : string = '' , ) {
if ( defaultColor && allowsNone ) {
throw new Error ( "Allowing an empty value is not compatible with a default color" ) ;
}
}
}
/ * *
* colorSelect allows to select color for both fill and text cell color . It allows for fast
@ -13,98 +41,118 @@ import { defaultMenuOptions, IOpenController, setPopupToCreateDom } from "popwea
* 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 | undefined > , fillColor : Observable < string | undefined > ,
onSave : ( ) = > Promise < void > , allowNone = false ) : Element {
export function colorSelect (
styleOptions : StyleOptions ,
onSave : ( ) = > Promise < void > ,
placeholder = 'Default cell style' ) : Element {
const {
textColor ,
fillColor ,
} = styleOptions ;
const selectBtn = cssSelectBtn (
cssContent (
cssButtonIcon (
'T' ,
dom . style ( 'color' , use = > use ( textColor ) || '' ) ,
dom . style ( 'background-color' , ( use ) = > use ( fillColor ) ? . slice ( 0 , 7 ) || '' ) ,
dom . style ( 'color' , use = > use ( textColor . color ) || textColor . previewNoneColor ) ,
dom . style ( 'background-color' , ( use ) = > use ( fillColor . color ) ? . slice ( 0 , 7 ) || fillColor . previewNoneColor ) ,
dom . cls ( 'font-bold' , use = > use ( styleOptions . fontBold ) ? ? false ) ,
dom . cls ( 'font-italic' , use = > use ( styleOptions . fontItalic ) ? ? false ) ,
dom . cls ( 'font-underline' , use = > use ( styleOptions . fontUnderline ) ? ? false ) ,
dom . cls ( 'font-strikethrough' , use = > use ( styleOptions . fontStrikethrough ) ? ? false ) ,
cssLightBorder . cls ( '' ) ,
testId ( 'btn-icon' ) ,
) ,
'Cell Color' ,
placeholder ,
) ,
icon ( 'Dropdown' ) ,
testId ( 'color-select' ) ,
) ;
const domCreator = ( ctl : IOpenController ) = > buildColorPicker ( ctl , textColor, fillColor , onSave , allowNon e) ;
const domCreator = ( ctl : IOpenController ) = > buildColorPicker ( ctl , styleOptions, onSav e) ;
setPopupToCreateDom ( selectBtn , domCreator , { . . . defaultMenuOptions , placement : 'bottom-end' } ) ;
return selectBtn ;
}
export function colorButton ( textColor : Observable < string | undefined > , fillColor : Observable < string | undefined > ,
export function colorButton (
styleOptions : StyleOptions ,
onSave : ( ) = > Promise < void > ) : Element {
const { textColor , fillColor } = styleOptions ;
const iconBtn = cssIconBtn (
icon (
'Dropdown' ,
dom . style ( 'background-color' , use = > use ( textColor) || '' ) ,
testId ( 'color-button-dropdown' )
) ,
dom . style( 'background-color' , ( use ) = > use ( fillColor ) ? . slice ( 0 , 7 ) || '' ) ,
dom . on( 'click' , ( e ) = > { e . stopPropagation ( ) ; e . preventDefault ( ) ; } ) ,
'T' ,
dom . style ( 'color' , use = > use ( textColor . color ) || textColor . previewNoneColor ) ,
dom . style ( 'background-color' , ( use ) = > use ( fillColor. color ) ? . slice ( 0 , 7 ) || fillColor . previewNoneColor ) ,
dom . cls ( 'font-bold' , use = > use ( styleOptions . fontBold ) ? ? false ) ,
dom . cls ( 'font-italic' , use = > use ( styleOptions . fontItalic ) ? ? false ) ,
dom . cls( 'font-underline' , use = > use ( styleOptions . fontUnderline ) ? ? false ) ,
dom . cls( 'font-strikethrough' , use = > use ( styleOptions . fontStrikethrough ) ? ? false ) ,
testId ( 'color-button' ) ,
) ;
const domCreator = ( ctl : IOpenController ) = > buildColorPicker ( ctl , textColor, fillColor , onSave ) ;
const domCreator = ( ctl : IOpenController ) = > buildColorPicker ( ctl , styleOptions , onSave ) ;
setPopupToCreateDom ( iconBtn , domCreator , { . . . defaultMenuOptions , placement : 'bottom-end' } ) ;
return iconBtn ;
}
function buildColorPicker ( ctl : IOpenController , textColor : Observable < string | undefined > ,
fillColor : Observable < string | undefined > ,
onSave : ( ) = > Promise < void > ,
allowNone = false ) : Element {
const textColorModel = PickerModel . create ( null , textColor ) ;
const fillColorModel = PickerModel . create ( null , fillColor ) ;
function buildColorPicker ( ctl : IOpenController ,
{
textColor ,
fillColor ,
fontBold ,
fontUnderline ,
fontItalic ,
fontStrikethrough
} : StyleOptions ,
onSave : ( ) = > Promise < void > ) : Element {
const textColorModel = ColorModel . create ( null , textColor . color ) ;
const fillColorModel = ColorModel . create ( null , fillColor . color ) ;
const fontBoldModel = BooleanModel . create ( null , fontBold ) ;
const fontUnderlineModel = BooleanModel . create ( null , fontUnderline ) ;
const fontItalicModel = BooleanModel . create ( null , fontItalic ) ;
const fontStrikethroughModel = BooleanModel . create ( null , fontStrikethrough ) ;
const models = [ textColorModel , fillColorModel , fontBoldModel , fontUnderlineModel ,
fontItalicModel , fontStrikethroughModel ] ;
const notChanged = Computed . create ( null , use = > models . every ( m = > use ( m . needsSaving ) === false ) ) ;
function revert() {
textColorModel . revert ( ) ;
fillColorModel . revert ( ) ;
models . forEach ( m = > m . revert ( ) ) ;
ctl . close ( ) ;
}
ctl . onDispose ( async ( ) = > {
if ( textColorModel . needsSaving ( ) || fillColorModel . needsSaving ( ) ) {
if ( ! notChanged . get ( ) ) {
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 ( ) ;
models . forEach ( m = > m . revert ( ) ) ;
}
}
textColorModel. dispose ( ) ;
fillColorModel . dispose ( ) ;
models. forEach ( m = > m . dispose ( ) ) ;
notChanged . dispose ( ) ;
} ) ;
const colorSquare = ( . . . args : DomArg [ ] ) = > cssColorSquare (
. . . args ,
dom . style ( 'color' , use = > use ( textColor ) || '' ) ,
dom . style ( 'background-color' , use = > use ( fillColor ) || '' ) ,
cssLightBorder . cls ( '' ) ,
) ;
return cssContainer (
dom . create ( PickerComponent, fillColorModel , {
colorSquare: colorSquare ( ) ,
title: 'fill' ,
defaultMode: 'lighter' ,
allowNone
dom . create ( FontComponent , {
fontBoldModel ,
fontUnderlineModel ,
fontItalicModel ,
fontStrikethroughModel ,
} ) ,
cssVSpacer ( ) ,
dom . create ( PickerComponent , textColorModel , {
colorSquare : colorSquare ( 'T' ) ,
title : 'text' ,
defaultMode : 'darker' ,
allowNone
. . . textColor
} ) ,
cssVSpacer ( ) ,
dom . create ( PickerComponent , fillColorModel , {
title : 'fill' ,
. . . fillColor
} ) ,
// gives focus and binds keydown events
( elem : any ) = > { setTimeout ( ( ) = > elem . focus ( ) , 0 ) ; } ,
onKeyDown ( {
@ -112,36 +160,53 @@ function buildColorPicker(ctl: IOpenController, textColor: Observable<string|und
Enter : ( ) = > { ctl . close ( ) ; } ,
} ) ,
cssButtonRow (
primaryButton ( 'Apply' ,
dom . on ( 'click' , ( ) = > ctl . close ( ) ) ,
dom . boolAttr ( "disabled" , notChanged ) ,
testId ( 'colors-save' )
) ,
basicButton ( 'Cancel' ,
dom . on ( 'click' , ( ) = > revert ( ) ) ,
testId ( 'colors-cancel' )
)
) ,
// 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' ;
allowNone? : boolean ;
}
// 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 ( ) ;
class PickerModel < T extends boolean | string | undefined > extends Disposable {
// Is current value different from the server value?
public needsSaving : Observable < boolean > ;
private _serverValue : Observable < T > ;
private _localChange : boolean = false ;
constructor ( public obs : Observable < string | undefined > ) {
constructor ( public obs : Observable < T > ) {
super ( ) ;
this . _serverValue = Observable . create ( this , this . obs . get ( ) ) ;
this . needsSaving = Computed . create ( this , use = > {
const current = use ( this . obs ) ;
const server = use ( this . _serverValue ) ;
// We support booleans and strings only for now, so if current is false and server
// is undefined, we assume they are the same.
// TODO: this probably should be a strategy method.
return current !== ( typeof current === 'boolean' ? ( server ? ? false ) : server ) ;
} ) ;
this . autoDispose ( this . obs . addListener ( ( val ) = > {
if ( this . _localChange ) { return ; }
this . _serverValue = val ;
this . _serverValue . set ( val ) ;
} ) ) ;
}
// Set the value picked by the user
public setValue ( val : string | undefined ) {
public setValue ( val : T ) {
this . _localChange = true ;
this . obs . set ( val ) ;
this . _localChange = false ;
@ -149,31 +214,50 @@ class PickerModel extends Disposable {
// 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 ;
this . obs . set ( this . _serverValue . get ( ) ) ;
}
}
class ColorModel extends PickerModel < string | undefined > { }
class BooleanModel extends PickerModel < boolean | undefined > { }
interface PickerComponentOptions {
title : string ;
allowsNone : boolean ;
// Default color to show when value is empty or undefined (itself can be empty).
defaultColor : string ;
// Text to be shown in the picker when color is not set.
noneText : string ;
// Preview color to show when value is undefined.
previewNoneColor : string ;
}
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 ( ) ) ;
private _color = Computed . create ( this ,
this . _model . obs ,
( use , val ) = > ( val || this . _options . defaultColor ) . toUpperCase ( ) . slice ( 0 , 7 ) ) ;
constructor ( private _model : PickerModel , private _options : PickerComponentOptions ) {
constructor (
private _model : PickerModel < string | undefined > ,
private _options : PickerComponentOptions ) {
super ( ) ;
}
public buildDom() {
const title = this . _options . title ;
const colorText = Computed . create ( null , use = > use ( this . _color ) || this . _options . noneText ) ;
return [
cssHeaderRow (
cssHeaderRow ( title ) ,
cssControlRow (
cssColorPreview (
dom . update (
this . _options . colorSquare ,
cssColorSquare (
cssLightBorder . cls ( '' ) ,
dom . style ( 'background-color' , this . _color ) ,
cssNoneIcon ( 'Empty' ,
dom . hide ( use = > Boolean ( use ( this . _color ) ) === true )
) ,
) ,
cssColorInput (
{ type : 'color' } ,
dom . attr ( 'value' , this . _color ) ,
@ -182,52 +266,38 @@ class PickerComponent extends Disposable {
) ,
) ,
cssHexBox (
this . _ color,
colorText ,
async ( val ) = > {
if ( ( this . _options . allowNone && ! val ) || isValidHex ( val ) ) {
this . _model . setValue ( val ) ;
if ( ! val || isValidHex ( val ) ) {
this . _model . setValue ( val || undefined ) ;
}
} ,
dom . autoDispose ( colorText ) ,
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 ` ) ,
) ,
) ,
cssEmptyBox (
cssEmptyBox . cls ( '-selected' , ( use ) = > ! use ( this . _color ) ) ,
dom . on ( 'click' , ( ) = > this . _setValue ( undefined ) ) ,
dom . hide ( ! this . _options . allowsNone ) ,
cssNoneIcon ( 'Empty' ) ,
testId ( ` ${ title } -empty ` ) ,
)
) ,
cssPalette (
dom. domComputed ( this . _mode , ( mode ) = > ( mode === 'lighter' ? lighter : darker ) . map ( color = > (
swatches . map ( ( color , index ) = > (
cssColorSquare (
dom . style ( 'background-color' , color ) ,
cssLightBorder . cls ( '' , ( use ) = > use ( this . _mode ) === 'lighter' ) ,
cssLightBorder . cls ( '' , isLight ( index ) ) ,
cssColorSquare . cls ( '-selected' , ( use ) = > use ( this . _color ) === color ) ,
dom . style ( 'outline-color' , ( use ) = > use ( this . _mode ) === 'lighter' ? '' : color ) ,
dom . on ( 'click' , ( ) = > {
// Clicking same color twice - removes the selection.
if ( this . _model . obs . get ( ) === color &&
this . _options . allowNone ) {
this . _setValue ( undefined ) ;
} else {
this . _setValue ( color ) ;
}
} ) ,
dom . style ( 'outline-color' , isLight ( index ) ? '' : color ) ,
dom . on ( 'click' , ( ) = > this . _setValue ( color ) ) ,
testId ( ` color- ${ color } ` ) ,
)
) ) ) ,
) ) ,
testId ( ` ${ title } -palette ` ) ,
) ,
] ;
@ -236,18 +306,61 @@ class PickerComponent extends Disposable {
private _setValue ( val : string | undefined ) {
this . _model . setValue ( val ) ;
}
}
private _guessMode ( ) : 'darker' | 'lighter' {
if ( lighter . indexOf ( this . _color . get ( ) ) > - 1 ) {
return 'lighter' ;
class FontComponent extends Disposable {
constructor (
private _options : {
fontBoldModel : BooleanModel ,
fontUnderlineModel : BooleanModel ,
fontItalicModel : BooleanModel ,
fontStrikethroughModel : BooleanModel ,
}
if ( darker . indexOf ( this . _color . get ( ) ) > - 1 ) {
return 'darker' ;
) {
super ( ) ;
}
public buildDom() {
function option ( iconName : IconName , model : BooleanModel ) {
return cssFontOption (
cssFontIcon ( iconName ) ,
dom . on ( 'click' , ( ) = > model . setValue ( ! model . obs . get ( ) ) ) ,
cssFontOption . cls ( '-selected' , use = > use ( model . obs ) ? ? false ) ,
testId ( ` font-option- ${ iconName } ` )
) ;
}
return this . _options . defaultMode ;
return cssFontOptions (
option ( 'FontBold' , this . _options . fontBoldModel ) ,
option ( 'FontUnderline' , this . _options . fontUnderlineModel ) ,
option ( 'FontItalic' , this . _options . fontItalicModel ) ,
option ( 'FontStrikethrough' , this . _options . fontStrikethroughModel ) ,
) ;
}
}
const cssFontOptions = styled ( 'div' , `
display : flex ;
gap : 1px ;
background : $ { colors . darkGrey } ;
border : 1px solid $ { colors . darkGrey } ;
` );
const cssFontOption = styled ( 'div' , `
display : grid ;
place - items : center ;
flex - grow : 1 ;
background : white ;
height : 24px ;
cursor : pointer ;
& : hover :not ( & - selected ) {
background : $ { colors . lightGrey } ;
}
& - selected {
background : $ { colors . dark } ;
-- icon - color : $ { colors . light }
}
` );
const cssColorInput = styled ( 'input' , `
opacity : 0 ;
position : absolute ;
@ -259,39 +372,25 @@ const cssColorInput = styled('input', `
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 2 px # FFFFFF ;
& - selected {
border : 1px solid # 414141 ;
box - shadow : inset 0 0 0 1 px # FFFFFF ;
}
& - lighter {
background - color : # DCDCDC ;
}
` );
const cssColorPreview = styled ( 'div' , `
display : flex ;
` );
const css Header Row = styled ( 'div' , `
const cssControlRow = styled ( 'div' , `
display : flex ;
justify - content : space - between ;
margin - bottom : 8px ;
text - transform : capitalize ;
` );
const cssHeaderRow = styled ( 'div' , `
text - transform : uppercase ;
font - size : $ { vars . smallFontSize } ;
margin - bottom : 12px ;
` );
const cssPalette = styled ( 'div' , `
width : 236px ;
height : 68px ;
height : calc( 4 * 20 px + 3 * 4 px ) ;
display : flex ;
flex - direction : column ;
flex - wrap : wrap ;
@ -300,7 +399,7 @@ const cssPalette = styled('div', `
` );
const cssVSpacer = styled ( 'div' , `
height : 1 2px;
height : 24 px;
` );
const cssContainer = styled ( 'div' , `
@ -320,7 +419,7 @@ const cssContent = styled('div', `
` );
const cssHexBox = styled ( textInput , `
border : 1px solid # D9D9D9 ;
border : 1px solid $ { colors . darkGrey } ;
border - left : none ;
font - size : $ { vars . smallFontSize } ;
display : flex ;
@ -350,17 +449,42 @@ const cssColorSquare = styled('div', `
}
` );
const cssEmptyBox = styled ( cssColorSquare , `
-- icon - color : $ { colors . error } ;
border : 1px solid # D9D9D9 ;
& - selected {
outline : 1px solid $ { colors . dark } ;
outline - offset : 1px ;
}
` );
const cssFontIcon = styled ( icon , `
height : 12px ;
width : 12px ;
` );
const cssNoneIcon = styled ( icon , `
height : 100 % ;
width : 100 % ;
-- icon - color : $ { colors . error }
` );
const cssButtonIcon = styled ( cssColorSquare , `
margin - right : 6px ;
margin - left : 4px ;
` );
const cssIconBtn = styled ( 'div' , `
const cssIconBtn = styled ( cssColorSquare , `
min - width : 18px ;
width : 18px ;
height : 18px ;
cursor : pointer ;
display : grid ;
place - items : center ;
` );
const cssButtonRow = styled ( 'div' , `
gap : 8px ;
display : flex ;
align - items : center ;
justify - content : center ;
margin - top : 24px ;
` );