gristlabs_grist-core/app/client/widgets/NumericSpinner.ts

173 lines
4.7 KiB
TypeScript
Raw Normal View History

import {theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {clamp, numberOrDefault} from 'app/common/gutil';
import {MaybePromise} from 'app/plugin/gutil';
import {BindableValue, dom, DomElementArg, IDomArgs, makeTestId, Observable, styled} from 'grainjs';
const testId = makeTestId('test-numeric-spinner-');
export interface NumericSpinnerOptions {
/** Defaults to `false`. */
setValueOnInput?: boolean;
label?: string;
defaultValue?: number | Observable<number>;
/** No minimum if unset. */
minValue?: number;
/** No maximum if unset. */
maxValue?: number;
disabled?: BindableValue<boolean>;
inputArgs?: IDomArgs<HTMLInputElement>;
/** Called on blur and spinner button click. */
save?: (val?: number) => MaybePromise<void>,
}
export function numericSpinner(
value: Observable<number | ''>,
options: NumericSpinnerOptions = {},
...args: DomElementArg[]
) {
const {
setValueOnInput = false,
label,
defaultValue,
minValue = Number.NEGATIVE_INFINITY,
maxValue = Number.POSITIVE_INFINITY,
disabled,
inputArgs = [],
save,
} = options;
const getDefaultValue = () => {
if (defaultValue === undefined) {
return 0;
} else if (typeof defaultValue === 'number') {
return defaultValue;
} else {
return defaultValue.get();
}
};
let inputElement: HTMLInputElement;
const shiftValue = async (delta: 1 | -1, opts: {saveValue?: boolean} = {}) => {
const {saveValue} = opts;
const currentValue = numberOrDefault(inputElement.value, getDefaultValue());
const newValue = clamp(Math.floor(currentValue + delta), minValue, maxValue);
if (setValueOnInput) { value.set(newValue); }
if (saveValue) { await save?.(newValue); }
return newValue;
};
const incrementValue = (opts: {saveValue?: boolean} = {}) => shiftValue(1, opts);
const decrementValue = (opts: {saveValue?: boolean} = {}) => shiftValue(-1, opts);
return cssNumericSpinner(
disabled ? cssNumericSpinner.cls('-disabled', disabled) : null,
label ? cssNumLabel(label) : null,
inputElement = cssNumInput(
{type: 'number'},
dom.prop('value', value),
defaultValue !== undefined ? dom.prop('placeholder', defaultValue) : null,
dom.onKeyDown({
ArrowUp: async (_ev, elem) => { elem.value = String(await incrementValue()); },
ArrowDown: async (_ev, elem) => { elem.value = String(await decrementValue()); },
Enter$: async (_ev, elem) => save && elem.blur(),
}),
!setValueOnInput ? null : dom.on('input', (_ev, elem) => {
value.set(Number.parseFloat(elem.value));
}),
!save ? null : dom.on('blur', async () => {
let newValue = numberOrDefault(inputElement.value, undefined);
if (newValue !== undefined) { newValue = clamp(newValue, minValue, maxValue); }
await save(newValue);
}),
dom.on('focus', (_ev, elem) => elem.select()),
...inputArgs,
),
cssSpinner(
cssSpinnerBtn(
cssSpinnerTop('DropdownUp'),
dom.on('click', async () => incrementValue({saveValue: true})),
testId('increment'),
),
cssSpinnerBtn(
cssSpinnerBottom('Dropdown'),
dom.on('click', async () => decrementValue({saveValue: true})),
testId('decrement'),
),
),
...args
);
}
const cssNumericSpinner = styled('div', `
position: relative;
flex: auto;
font-weight: normal;
display: flex;
align-items: center;
outline: 1px solid ${theme.inputBorder};
background-color: ${theme.inputBg};
border-radius: 3px;
&-disabled {
opacity: 0.4;
pointer-events: none;
}
`);
const cssNumLabel = styled('div', `
color: ${theme.lightText};
flex-shrink: 0;
padding-left: 8px;
pointer-events: none;
`);
const cssNumInput = styled('input', `
flex-grow: 1;
padding: 4px 32px 4px 8px;
width: 100%;
text-align: right;
appearance: none;
color: ${theme.inputFg};
background-color: transparent;
border: none;
outline: none;
-moz-appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
`);
const cssSpinner = styled('div', `
position: absolute;
right: 8px;
width: 16px;
height: 100%;
display: flex;
flex-direction: column;
`);
const cssSpinnerBtn = styled('div', `
--icon-color: ${theme.controlSecondaryFg};
flex: 1 1 0px;
min-height: 0px;
position: relative;
cursor: pointer;
overflow: hidden;
&:hover {
--icon-color: ${theme.controlSecondaryHoverFg};
}
`);
const cssSpinnerTop = styled(icon, `
position: absolute;
top: 0px;
`);
const cssSpinnerBottom = styled(icon, `
position: absolute;
bottom: 0px;
`);