mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
265 lines
7.6 KiB
TypeScript
265 lines
7.6 KiB
TypeScript
|
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', value),
|
||
|
dom.on('input', setValue),
|
||
|
dom.on('change', onSave)
|
||
|
),
|
||
|
dom.style('background-color', (use) => use(value) || '#ffffff'),
|
||
|
cssColorBtn.cls('-dark', (use) => isColorDark(use(value) || '#ffffff')),
|
||
|
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};
|
||
|
}
|
||
|
`);
|