gristlabs_grist-core/app/client/ui2018/buttonSelect.ts

265 lines
7.6 KiB
TypeScript
Raw Normal View History

import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import {IconName} from 'app/client/ui2018/IconList';
import {icon} from 'app/client/ui2018/icons';
import {isColorDark} from 'app/common/gutil';
import {dom, DomElementArg, Observable, styled} from 'grainjs';
import debounce = require('lodash/debounce');
export interface ISelectorOptionFull<T> {
value: T;
label?: string;
icon?: IconName;
}
// For string options, we can use a string for label and value without wrapping into an object.
export type ISelectorOption<T> = (T & string) | ISelectorOptionFull<T>;
/**
* Creates a button select, which is a row of buttons of which only one may be selected.
* The observable `obs` reflects the value of the selected option, and `optionArray` is an array
* of option values and labels. These may be either strings, or {label, value, icon} objects.
* Icons and labels are optional (but one should be included or the buttons will be blank).
*
* The type of value may be any type at all; it is opaque to this widget.
*
* A "light" style is supported in CSS by passing cssButtonSelect.cls('-light') as an additional
* argument.
*
* Usage:
* const fruit = observable("apple");
* buttonSelect(fruit, ["apple", "banana", "mango"]);
*
* const alignments: ISelectorOption<string>[] = [
* {value: 'left', icon: 'LeftAlign'},
* {value: 'center', icon: 'CenterAlign'},
* {value: 'right', icon: 'RightAlign'}
* ];
* buttonSelect(obs, alignments);
*
*/
export function buttonSelect<T>(
obs: Observable<T>,
optionArray: Array<ISelectorOption<T>>,
...domArgs: DomElementArg[]
) {
return makeButtonSelect(obs, optionArray, (val: T) => { obs.set(val); }, ...domArgs);
}
/**
* Identical to a buttonSelect, but allows the possibility of none of the items being selected.
* Sets the observable `obs` to null when no items are selected.
*/
export function buttonToggleSelect<T>(
obs: Observable<T|null>,
optionArray: Array<ISelectorOption<T>>,
...domArgs: DomElementArg[]
) {
const onClick = (val: T) => { obs.set(obs.get() === val ? null : val); };
return makeButtonSelect(obs, optionArray, onClick, ...domArgs);
}
/**
* Pre-made text alignment selector.
*/
export function alignmentSelect(obs: Observable<string>) {
const alignments: Array<ISelectorOption<string>> = [
{value: 'left', icon: 'LeftAlign'},
{value: 'center', icon: 'CenterAlign'},
{value: 'right', icon: 'RightAlign'}
];
return buttonSelect(obs, alignments, {}, testId('alignment-select'));
}
/**
* Color selector button. Observable should contain a hex color value, e.g. #a4ba23.
*/
export function colorSelect(value: Observable<string>, save: (val: string) => Promise<void>,
...domArgs: DomElementArg[]) {
// On some machines (seen on chrome running on a Mac) the `change` event fires as many times as
// the `input` event, hence the debounce. Also note that when user picks a first color and then a
// second before closing the picker, it will create two user actions on Chrome, and only one in FF
// (which should be the expected behaviour).
const setValue = debounce(e => value.set(e.target.value), 300);
const onSave = debounce(e => save(e.target.value), 300);
return cssColorBtn(
// TODO: When re-opening the color picker after a new color was saved on server, the picker will
// reset the value to what it was when the picker was last closed. To allow picker to show the
// latest saved value we should rebind the <input .../> element each time the value is changed
// by the server.
cssColorPicker(
{type: 'color'},
dom.attr('value', (use) => use(value).slice(0, 7)),
dom.on('input', setValue),
dom.on('change', onSave)
),
dom.style('background-color', (use) => use(value) || '#000000'),
cssColorBtn.cls('-dark', (use) => isColorDark(use(value) || '#000000')),
cssColorIcon('Dots'),
...domArgs
);
}
export function makeButtonSelect<T>(
obs: Observable<T|null>,
optionArray: Array<ISelectorOption<T>>,
onClick: (value: T) => any,
...domArgs: DomElementArg[]
) {
return cssButtonSelect(
dom.forEach(optionArray, (option: ISelectorOption<T>) => {
const value = getOptionValue(option);
const label = getOptionLabel(option);
return cssSelectorBtn(
cssSelectorBtn.cls('-selected', (use) => use(obs) === value),
dom.on('click', () => onClick(value)),
isFullOption(option) && option.icon ? icon(option.icon) : null,
label ? cssSelectorLabel(label) : null,
testId('select-button')
);
}),
...domArgs
);
}
function isFullOption<T>(option: ISelectorOption<T>): option is ISelectorOptionFull<T> {
return typeof option !== "string";
}
function getOptionLabel<T>(option: ISelectorOption<T>): string|undefined {
return isFullOption(option) ? option.label : option;
}
function getOptionValue<T>(option: ISelectorOption<T>): T {
return isFullOption(option) ? option.value : option;
}
export const cssButtonSelect = styled('div', `
/* Resets */
position: relative;
outline: none;
border-style: none;
display: flex;
/* Vars */
color: ${colors.dark};
flex: 1 1 0;
`);
const cssSelectorBtn = styled('div', `
/* Resets */
position: relative;
outline: none;
border-style: none;
display: flex;
align-items: center;
justify-content: center;
/* Vars */
flex: 1 1 0;
font-size: ${vars.mediumFontSize};
letter-spacing: -0.08px;
text-align: center;
line-height: normal;
min-width: 32px;
white-space: nowrap;
padding: 4px 10px;
border: 1px solid ${colors.darkGrey};
--icon-color: ${colors.slate};
margin-left: -1px;
cursor: pointer;
&:first-child {
border-top-left-radius: ${vars.controlBorderRadius};
border-bottom-left-radius: ${vars.controlBorderRadius};
margin-left: 0;
}
&:last-child {
border-top-right-radius: ${vars.controlBorderRadius};
border-bottom-right-radius: ${vars.controlBorderRadius};
}
&:hover:not(&-selected) {
border: 1px solid ${colors.hover};
z-index: 5; /* Update z-index so selected borders take precedent */
}
&-selected {
color: ${colors.light};
--icon-color: ${colors.light};
border: 1px solid ${colors.dark};
background-color: ${colors.dark};
z-index: 10; /* Update z-index so selected borders take precedent */
}
/* Styles when container includes cssButtonSelect.cls('-light') */
.${cssButtonSelect.className}-light > & {
border: none;
border-radius: ${vars.controlBorderRadius};
margin-left: 0px;
padding: 8px;
color: ${colors.slate};
--icon-color: ${colors.slate};
}
.${cssButtonSelect.className}-light > &-selected {
border: none;
color: ${colors.lightGreen};
--icon-color: ${colors.lightGreen};
background-color: initial;
}
.${cssButtonSelect.className}-light > &:hover {
border: none;
background-color: ${colors.mediumGrey};
}
`);
const cssSelectorLabel = styled('span', `
margin: 0 2px;
vertical-align: middle;
`);
const cssColorBtn = styled('div', `
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
min-width: 32px;
max-width: 56px;
height: 32px;
border-radius: 4px;
border: 1px solid ${colors.darkGrey};
&:hover {
border: 1px solid ${colors.hover};
}
&-dark {
border: none !important;
}
`);
const cssColorPicker = styled('input', `
position: absolute;
cursor: pointer;
opacity: 0;
top: 0;
left: 0;
width: 100%;
height: 100%;
`);
const cssColorIcon = styled(icon, `
margin: 0 2px;
background-color: ${colors.slate};
pointer-events: none;
.${cssColorBtn.className}-dark & {
background-color: ${colors.light};
}
`);