mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) New Grist Forms styling and field options
Summary: - New styling for forms. - New field options for various field types (spinner, checkbox, radio buttons, alignment, sort). - Improved alignment of form fields in columns. - Support for additional select input keyboard shortcuts (Enter and Backspace). - Prevent submitting form on Enter if an input has focus. - Fix for changing form field type causing the field to disappear. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D4223
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
import {
|
||||
FormFieldRulesConfig,
|
||||
FormOptionsAlignmentConfig,
|
||||
FormOptionsSortConfig,
|
||||
} from 'app/client/components/Forms/FormConfig';
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {testId} from 'app/client/ui2018/cssVars';
|
||||
import {
|
||||
ChoiceOptionsByName,
|
||||
ChoiceTextBox,
|
||||
} from 'app/client/widgets/ChoiceTextBox';
|
||||
import {choiceToken} from 'app/client/widgets/ChoiceToken';
|
||||
import {CellValue} from 'app/common/DocActions';
|
||||
import {decodeObject} from 'app/plugin/objtypes';
|
||||
import {dom, styled} from 'grainjs';
|
||||
import {choiceToken} from 'app/client/widgets/ChoiceToken';
|
||||
|
||||
/**
|
||||
* ChoiceListCell - A cell that renders a list of choice tokens.
|
||||
@@ -49,6 +54,15 @@ export class ChoiceListCell extends ChoiceTextBox {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public buildFormConfigDom() {
|
||||
return [
|
||||
this.buildChoicesConfigDom(),
|
||||
dom.create(FormOptionsAlignmentConfig, this.field),
|
||||
dom.create(FormOptionsSortConfig, this.field),
|
||||
dom.create(FormFieldRulesConfig, this.field),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export const cssChoiceList = styled('div', `
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
FormFieldRulesConfig,
|
||||
FormOptionsSortConfig,
|
||||
FormSelectConfig,
|
||||
} from 'app/client/components/Forms/FormConfig';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
@@ -76,7 +81,7 @@ export class ChoiceTextBox extends NTextBox {
|
||||
public buildConfigDom() {
|
||||
return [
|
||||
super.buildConfigDom(),
|
||||
this._buildChoicesConfigDom(),
|
||||
this.buildChoicesConfigDom(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -86,14 +91,16 @@ export class ChoiceTextBox extends NTextBox {
|
||||
|
||||
public buildFormConfigDom() {
|
||||
return [
|
||||
this._buildChoicesConfigDom(),
|
||||
super.buildFormConfigDom(),
|
||||
this.buildChoicesConfigDom(),
|
||||
dom.create(FormSelectConfig, this.field),
|
||||
dom.create(FormOptionsSortConfig, this.field),
|
||||
dom.create(FormFieldRulesConfig, this.field),
|
||||
];
|
||||
}
|
||||
|
||||
public buildFormTransformConfigDom() {
|
||||
return [
|
||||
this._buildChoicesConfigDom(),
|
||||
this.buildChoicesConfigDom(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -113,7 +120,7 @@ export class ChoiceTextBox extends NTextBox {
|
||||
return this.field.config.updateChoices(renames, options);
|
||||
}
|
||||
|
||||
private _buildChoicesConfigDom() {
|
||||
protected buildChoicesConfigDom() {
|
||||
const disabled = Computed.create(null,
|
||||
use => use(this.field.disableModify)
|
||||
|| use(use(this.field.column).disableEditData)
|
||||
|
||||
@@ -6,7 +6,7 @@ var kd = require('../lib/koDom');
|
||||
var kf = require('../lib/koForm');
|
||||
var AbstractWidget = require('./AbstractWidget');
|
||||
|
||||
const {FieldRulesConfig} = require('app/client/components/Forms/FormConfig');
|
||||
const {FormFieldRulesConfig} = require('app/client/components/Forms/FormConfig');
|
||||
const {fromKoSave} = require('app/client/lib/fromKoSave');
|
||||
const {alignmentSelect, cssButtonSelect} = require('app/client/ui2018/buttonSelect');
|
||||
const {cssLabel, cssRow} = require('app/client/ui/RightPanelStyles');
|
||||
@@ -82,7 +82,7 @@ DateTextBox.prototype.buildTransformConfigDom = function() {
|
||||
|
||||
DateTextBox.prototype.buildFormConfigDom = function() {
|
||||
return [
|
||||
gdom.create(FieldRulesConfig, this.field),
|
||||
gdom.create(FormFieldRulesConfig, this.field),
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -108,12 +108,11 @@ export class FieldBuilder extends Disposable {
|
||||
private readonly _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>;
|
||||
private readonly _docModel: DocModel;
|
||||
private readonly _readonly: Computed<boolean>;
|
||||
private readonly _isForm: ko.Computed<boolean>;
|
||||
private readonly _comments: ko.Computed<boolean>;
|
||||
private readonly _showRefConfigPopup: ko.Observable<boolean>;
|
||||
private readonly _isEditorActive = Observable.create(this, false);
|
||||
|
||||
|
||||
|
||||
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
|
||||
private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
|
||||
super();
|
||||
@@ -128,9 +127,13 @@ export class FieldBuilder extends Disposable {
|
||||
this._readonly = Computed.create(this, (use) =>
|
||||
use(gristDoc.isReadonly) || use(field.disableEditData) || Boolean(this._options.isPreview));
|
||||
|
||||
this._isForm = this.autoDispose(ko.computed(() => {
|
||||
return this.field.viewSection().widgetType() === WidgetType.Form;
|
||||
}));
|
||||
|
||||
// Observable with a list of available types.
|
||||
this._availableTypes = Computed.create(this, (use) => {
|
||||
const isForm = use(use(this.field.viewSection).widgetType) === WidgetType.Form;
|
||||
const isForm = use(this._isForm);
|
||||
const isFormula = use(this.origColumn.isFormula);
|
||||
const types: Array<IOptionFull<string>> = [];
|
||||
_.each(UserType.typeDefs, (def: any, key: string|number) => {
|
||||
@@ -201,8 +204,11 @@ export class FieldBuilder extends Disposable {
|
||||
|
||||
// Returns the constructor for the widget, and only notifies subscribers on changes.
|
||||
this._widgetCons = this.autoDispose(koUtil.withKoUtils(ko.computed(() => {
|
||||
return UserTypeImpl.getWidgetConstructor(this.options().widget,
|
||||
this._readOnlyPureType());
|
||||
if (this._isForm()) {
|
||||
return UserTypeImpl.getFormWidgetConstructor(this.options().widget, this._readOnlyPureType());
|
||||
} else {
|
||||
return UserTypeImpl.getWidgetConstructor(this.options().widget, this._readOnlyPureType());
|
||||
}
|
||||
})).onlyNotifyUnequal());
|
||||
|
||||
// Computed builder for the widget.
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { FieldRulesConfig } from 'app/client/components/Forms/FormConfig';
|
||||
import { FormFieldRulesConfig } from 'app/client/components/Forms/FormConfig';
|
||||
import { fromKoSave } from 'app/client/lib/fromKoSave';
|
||||
import { makeT } from 'app/client/lib/localization';
|
||||
import { DataRowModel } from 'app/client/models/DataRowModel';
|
||||
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
|
||||
import { cssRow } from 'app/client/ui/RightPanelStyles';
|
||||
import { alignmentSelect, cssButtonSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect';
|
||||
import { fieldWithDefault } from 'app/client/models/modelUtil';
|
||||
import { FormTextFormat } from 'app/client/ui/FormAPI';
|
||||
import { cssLabel, cssNumericSpinner, cssRow } from 'app/client/ui/RightPanelStyles';
|
||||
import { alignmentSelect, buttonSelect, cssButtonSelect, makeButtonSelect } from 'app/client/ui2018/buttonSelect';
|
||||
import { testId } from 'app/client/ui2018/cssVars';
|
||||
import { makeLinks } from 'app/client/ui2018/links';
|
||||
import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget';
|
||||
import { Computed, dom, DomContents, fromKo, Observable } from 'grainjs';
|
||||
import { makeT } from 'app/client/lib/localization';
|
||||
|
||||
const t = makeT('NTextBox');
|
||||
|
||||
@@ -60,8 +62,42 @@ export class NTextBox extends NewAbstractWidget {
|
||||
}
|
||||
|
||||
public buildFormConfigDom(): DomContents {
|
||||
const format = fieldWithDefault<FormTextFormat>(
|
||||
this.field.widgetOptionsJson.prop('formTextFormat'),
|
||||
'singleline'
|
||||
);
|
||||
const lineCount = fieldWithDefault<number|"">(
|
||||
this.field.widgetOptionsJson.prop('formTextLineCount'),
|
||||
''
|
||||
);
|
||||
|
||||
return [
|
||||
dom.create(FieldRulesConfig, this.field),
|
||||
cssLabel(t('Field Format')),
|
||||
cssRow(
|
||||
buttonSelect(
|
||||
fromKoSave(format),
|
||||
[
|
||||
{value: 'singleline', label: t('Single line')},
|
||||
{value: 'multiline', label: t('Multi line')},
|
||||
],
|
||||
testId('tb-form-field-format'),
|
||||
),
|
||||
),
|
||||
dom.maybe(use => use(format) === 'multiline', () =>
|
||||
cssRow(
|
||||
cssNumericSpinner(
|
||||
fromKo(lineCount),
|
||||
{
|
||||
label: t('Lines'),
|
||||
defaultValue: 3,
|
||||
minValue: 1,
|
||||
maxValue: 99,
|
||||
save: async (val) => lineCount.setAndSave((val && Math.floor(val)) ?? ''),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
dom.create(FormFieldRulesConfig, this.field),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
172
app/client/widgets/NumericSpinner.ts
Normal file
172
app/client/widgets/NumericSpinner.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
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;
|
||||
`);
|
||||
@@ -1,23 +1,25 @@
|
||||
/**
|
||||
* See app/common/NumberFormat for description of options we support.
|
||||
*/
|
||||
import {FormFieldRulesConfig} from 'app/client/components/Forms/FormConfig';
|
||||
import {fromKoSave} from 'app/client/lib/fromKoSave';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
||||
import {cssButtonSelect, ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect';
|
||||
import {fieldWithDefault} from 'app/client/models/modelUtil';
|
||||
import {FormNumberFormat} from 'app/client/ui/FormAPI';
|
||||
import {cssLabel, cssNumericSpinner, cssRow} from 'app/client/ui/RightPanelStyles';
|
||||
import {buttonSelect, cssButtonSelect, ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect';
|
||||
import {testId, theme} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker';
|
||||
import {NTextBox} from 'app/client/widgets/NTextBox';
|
||||
import {clamp} from 'app/common/gutil';
|
||||
import {numberOrDefault} from 'app/common/gutil';
|
||||
import {buildNumberFormat, NumberFormatOptions, NumMode, NumSign} from 'app/common/NumberFormat';
|
||||
import {BindableValue, Computed, dom, DomContents, DomElementArg,
|
||||
fromKo, MultiHolder, Observable, styled} from 'grainjs';
|
||||
import {Computed, dom, DomContents, fromKo, MultiHolder, styled} from 'grainjs';
|
||||
import * as LocaleCurrency from 'locale-currency';
|
||||
|
||||
|
||||
const t = makeT('NumericTextBox');
|
||||
|
||||
const modeOptions: Array<ISelectorOption<NumMode>> = [
|
||||
{value: 'currency', label: '$'},
|
||||
{value: 'decimal', label: ','},
|
||||
@@ -75,9 +77,10 @@ export class NumericTextBox extends NTextBox {
|
||||
};
|
||||
|
||||
// Prepare setters for the UI elements.
|
||||
// Min/max fraction digits may range from 0 to 20; other values are invalid.
|
||||
const setMinDecimals = (val?: number) => setSave('decimals', val && clamp(val, 0, 20));
|
||||
const setMaxDecimals = (val?: number) => setSave('maxDecimals', val && clamp(val, 0, 20));
|
||||
// If defined, `val` will be a floating point number between 0 and 20; make sure it's
|
||||
// saved as an integer.
|
||||
const setMinDecimals = (val?: number) => setSave('decimals', val && Math.floor(val));
|
||||
const setMaxDecimals = (val?: number) => setSave('maxDecimals', val && Math.floor(val));
|
||||
// Mode and Sign behave as toggles: clicking a selected on deselects it.
|
||||
const setMode = (val: NumMode) => setSave('numMode', val !== numMode.get() ? val : undefined);
|
||||
const setSign = (val: NumSign) => setSave('numSign', val !== numSign.get() ? val : undefined);
|
||||
@@ -105,16 +108,56 @@ export class NumericTextBox extends NTextBox {
|
||||
]),
|
||||
cssLabel(t('Decimals')),
|
||||
cssRow(
|
||||
decimals('min', minDecimals, defaultMin, setMinDecimals, disabled, testId('numeric-min-decimals')),
|
||||
decimals('max', maxDecimals, defaultMax, setMaxDecimals, disabled, testId('numeric-max-decimals')),
|
||||
cssNumericSpinner(
|
||||
minDecimals,
|
||||
{
|
||||
label: t('min'),
|
||||
minValue: 0,
|
||||
maxValue: 20,
|
||||
defaultValue: defaultMin,
|
||||
disabled,
|
||||
save: setMinDecimals,
|
||||
},
|
||||
testId('numeric-min-decimals'),
|
||||
),
|
||||
cssNumericSpinner(
|
||||
maxDecimals,
|
||||
{
|
||||
label: t('max'),
|
||||
minValue: 0,
|
||||
maxValue: 20,
|
||||
defaultValue: defaultMax,
|
||||
disabled,
|
||||
save: setMaxDecimals,
|
||||
},
|
||||
testId('numeric-max-decimals'),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function numberOrDefault<T>(value: unknown, def: T): number | T {
|
||||
return value !== null && value !== undefined ? Number(value) : def;
|
||||
public buildFormConfigDom(): DomContents {
|
||||
const format = fieldWithDefault<FormNumberFormat>(
|
||||
this.field.widgetOptionsJson.prop('formNumberFormat'),
|
||||
'text'
|
||||
);
|
||||
|
||||
return [
|
||||
cssLabel(t('Field Format')),
|
||||
cssRow(
|
||||
buttonSelect(
|
||||
fromKoSave(format),
|
||||
[
|
||||
{value: 'text', label: t('Text')},
|
||||
{value: 'spinner', label: t('Spinner')},
|
||||
],
|
||||
testId('numeric-form-field-format'),
|
||||
),
|
||||
),
|
||||
dom.create(FormFieldRulesConfig, this.field),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Helper used by setSave() above to reset some properties when switching modes.
|
||||
@@ -126,107 +169,6 @@ function updateOptions(prop: keyof NumberFormatOptions, value: unknown): Partial
|
||||
return {};
|
||||
}
|
||||
|
||||
function decimals(
|
||||
label: string,
|
||||
value: Observable<number | ''>,
|
||||
defaultValue: Observable<number>,
|
||||
setFunc: (val?: number) => void,
|
||||
disabled: BindableValue<boolean>,
|
||||
...args: DomElementArg[]
|
||||
) {
|
||||
return cssDecimalsBox(
|
||||
cssDecimalsBox.cls('-disabled', disabled),
|
||||
cssNumLabel(label),
|
||||
cssNumInput({type: 'text', size: '2', min: '0'},
|
||||
dom.prop('value', value),
|
||||
dom.prop('placeholder', defaultValue),
|
||||
dom.on('change', (ev, elem) => {
|
||||
const newVal = parseInt(elem.value, 10);
|
||||
// Set value explicitly before its updated via setFunc; this way the value reflects the
|
||||
// observable in the case the observable is left unchanged (e.g. because of clamping).
|
||||
elem.value = String(value.get());
|
||||
setFunc(Number.isNaN(newVal) ? undefined : newVal);
|
||||
elem.blur();
|
||||
}),
|
||||
dom.on('focus', (ev, elem) => elem.select()),
|
||||
),
|
||||
cssSpinner(
|
||||
cssSpinnerBtn(cssSpinnerTop('DropdownUp'),
|
||||
dom.on('click', () => setFunc(numberOrDefault(value.get(), defaultValue.get()) + 1))),
|
||||
cssSpinnerBtn(cssSpinnerBottom('Dropdown'),
|
||||
dom.on('click', () => setFunc(numberOrDefault(value.get(), defaultValue.get()) - 1))),
|
||||
),
|
||||
...args
|
||||
);
|
||||
}
|
||||
|
||||
const cssDecimalsBox = styled('div', `
|
||||
position: relative;
|
||||
flex: auto;
|
||||
--icon-color: ${theme.lightText};
|
||||
color: ${theme.lightText};
|
||||
font-weight: normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&:first-child {
|
||||
margin-right: 16px;
|
||||
}
|
||||
&-disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssNumLabel = styled('div', `
|
||||
position: absolute;
|
||||
padding-left: 8px;
|
||||
pointer-events: none;
|
||||
`);
|
||||
|
||||
const cssNumInput = styled('input', `
|
||||
padding: 4px 32px 4px 40px;
|
||||
border: 1px solid ${theme.inputBorder};
|
||||
border-radius: 3px;
|
||||
background-color: ${theme.inputBg};
|
||||
color: ${theme.inputFg};
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
appearance: none;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
`);
|
||||
|
||||
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;
|
||||
`);
|
||||
|
||||
const cssModeSelect = styled(makeButtonSelect, `
|
||||
flex: 4 4 0px;
|
||||
background-color: ${theme.inputBg};
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
FormFieldRulesConfig,
|
||||
FormOptionsSortConfig,
|
||||
FormSelectConfig
|
||||
} from 'app/client/components/Forms/FormConfig';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {TableRec} from 'app/client/models/DocModel';
|
||||
@@ -72,7 +77,9 @@ export class Reference extends NTextBox {
|
||||
public buildFormConfigDom() {
|
||||
return [
|
||||
this.buildTransformConfigDom(),
|
||||
super.buildFormConfigDom(),
|
||||
dom.create(FormSelectConfig, this.field),
|
||||
dom.create(FormOptionsSortConfig, this.field),
|
||||
dom.create(FormFieldRulesConfig, this.field),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
FormFieldRulesConfig,
|
||||
FormOptionsAlignmentConfig,
|
||||
FormOptionsSortConfig,
|
||||
} from 'app/client/components/Forms/FormConfig';
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {testId, theme} from 'app/client/ui2018/cssVars';
|
||||
@@ -103,6 +108,15 @@ export class ReferenceList extends Reference {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public buildFormConfigDom() {
|
||||
return [
|
||||
this.buildTransformConfigDom(),
|
||||
dom.create(FormOptionsAlignmentConfig, this.field),
|
||||
dom.create(FormOptionsSortConfig, this.field),
|
||||
dom.create(FormFieldRulesConfig, this.field),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const cssRefIcon = styled(icon, `
|
||||
|
||||
@@ -1,19 +1,44 @@
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import { FieldRulesConfig } from 'app/client/components/Forms/FormConfig';
|
||||
import { FormFieldRulesConfig } from 'app/client/components/Forms/FormConfig';
|
||||
import { fromKoSave } from 'app/client/lib/fromKoSave';
|
||||
import { makeT } from 'app/client/lib/localization';
|
||||
import { DataRowModel } from 'app/client/models/DataRowModel';
|
||||
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
|
||||
import { KoSaveableObservable } from 'app/client/models/modelUtil';
|
||||
import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget';
|
||||
import { fieldWithDefault, KoSaveableObservable } from 'app/client/models/modelUtil';
|
||||
import { FormToggleFormat } from 'app/client/ui/FormAPI';
|
||||
import { cssLabel, cssRow } from 'app/client/ui/RightPanelStyles';
|
||||
import { buttonSelect } from 'app/client/ui2018/buttonSelect';
|
||||
import { theme } from 'app/client/ui2018/cssVars';
|
||||
import { dom, DomContents } from 'grainjs';
|
||||
import { NewAbstractWidget, Options } from 'app/client/widgets/NewAbstractWidget';
|
||||
import { dom, DomContents, makeTestId } from 'grainjs';
|
||||
|
||||
const t = makeT('Toggle');
|
||||
|
||||
const testId = makeTestId('test-toggle-');
|
||||
|
||||
/**
|
||||
* ToggleBase - The base class for toggle widgets, such as a checkbox or a switch.
|
||||
*/
|
||||
abstract class ToggleBase extends NewAbstractWidget {
|
||||
public buildFormConfigDom(): DomContents {
|
||||
const format = fieldWithDefault<FormToggleFormat>(
|
||||
this.field.widgetOptionsJson.prop('formToggleFormat'),
|
||||
'switch'
|
||||
);
|
||||
|
||||
return [
|
||||
dom.create(FieldRulesConfig, this.field),
|
||||
cssLabel(t('Field Format')),
|
||||
cssRow(
|
||||
buttonSelect(
|
||||
fromKoSave(format),
|
||||
[
|
||||
{value: 'switch', label: t('Switch')},
|
||||
{value: 'checkbox', label: t('Checkbox')},
|
||||
],
|
||||
testId('form-field-format'),
|
||||
),
|
||||
),
|
||||
dom.create(FormFieldRulesConfig, this.field),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ export const typeDefs: any = {
|
||||
widgets: {
|
||||
TextBox: {
|
||||
cons: 'TextBox',
|
||||
formCons: 'Switch',
|
||||
editCons: 'TextEditor',
|
||||
icon: 'FieldTextbox',
|
||||
options: {
|
||||
|
||||
@@ -65,6 +65,12 @@ export function getWidgetConstructor(widget: string, type: string): WidgetConstr
|
||||
return nameToWidget[config.cons as keyof typeof nameToWidget] as any;
|
||||
}
|
||||
|
||||
/** return a good class to instantiate for viewing a form widget/type combination */
|
||||
export function getFormWidgetConstructor(widget: string, type: string): WidgetConstructor {
|
||||
const {config} = getWidgetConfiguration(widget, type as GristType);
|
||||
return nameToWidget[(config.formCons || config.cons) as keyof typeof nameToWidget] as any;
|
||||
}
|
||||
|
||||
/** return a good class to instantiate for editing a widget/type combination */
|
||||
export function getEditorConstructor(widget: string, type: string): typeof NewBaseEditor {
|
||||
const {config} = getWidgetConfiguration(widget, type as GristType);
|
||||
|
||||
Reference in New Issue
Block a user