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/D4223pull/936/head
parent
661f1c1804
commit
86062a8c28
@ -1,36 +1,144 @@
|
|||||||
import {makeT} from 'app/client/lib/localization';
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import * as css from 'app/client/ui/FormPagesCss';
|
import {colors, mediaSmall} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {commonUrls} from 'app/common/gristUrls';
|
import {commonUrls} from 'app/common/gristUrls';
|
||||||
import {DomContents, makeTestId} from 'grainjs';
|
import {DomContents, DomElementArg, styled} from 'grainjs';
|
||||||
|
|
||||||
const t = makeT('FormContainer');
|
const t = makeT('FormContainer');
|
||||||
|
|
||||||
const testId = makeTestId('test-form-');
|
export function buildFormMessagePage(buildBody: () => DomContents, ...args: DomElementArg[]) {
|
||||||
|
return cssFormMessagePage(
|
||||||
export function buildFormContainer(buildBody: () => DomContents) {
|
cssFormMessage(
|
||||||
return css.formContainer(
|
cssFormMessageBody(
|
||||||
css.form(
|
|
||||||
css.formBody(
|
|
||||||
buildBody(),
|
buildBody(),
|
||||||
),
|
),
|
||||||
css.formFooter(
|
cssFormMessageFooter(
|
||||||
css.poweredByGrist(
|
buildFormFooter(),
|
||||||
css.poweredByGristLink(
|
|
||||||
{href: commonUrls.forms, target: '_blank'},
|
|
||||||
t('Powered by'),
|
|
||||||
css.gristLogo(),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
css.buildForm(
|
|
||||||
css.buildFormLink(
|
|
||||||
{href: commonUrls.forms, target: '_blank'},
|
|
||||||
t('Build your own form'),
|
|
||||||
icon('Expand'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
testId('container'),
|
...args,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildFormFooter() {
|
||||||
|
return [
|
||||||
|
cssPoweredByGrist(
|
||||||
|
cssPoweredByGristLink(
|
||||||
|
{href: commonUrls.forms, target: '_blank'},
|
||||||
|
t('Powered by'),
|
||||||
|
cssGristLogo(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
cssBuildForm(
|
||||||
|
cssBuildFormLink(
|
||||||
|
{href: commonUrls.forms, target: '_blank'},
|
||||||
|
t('Build your own form'),
|
||||||
|
icon('Expand'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cssFormMessageImageContainer = styled('div', `
|
||||||
|
margin-top: 28px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssFormMessageImage = styled('img', `
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssFormMessageText = styled('div', `
|
||||||
|
color: ${colors.dark};
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
margin-top: 32px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssFormMessagePage = styled('div', `
|
||||||
|
padding: 16px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssFormMessage = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid ${colors.darkGrey};
|
||||||
|
border-radius: 3px;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0px auto;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssFormMessageBody = styled('div', `
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px 48px 20px 48px;
|
||||||
|
|
||||||
|
@media ${mediaSmall} {
|
||||||
|
& {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssFormMessageFooter = styled('div', `
|
||||||
|
border-top: 1px solid ${colors.darkGrey};
|
||||||
|
padding: 8px 16px;
|
||||||
|
width: 100%;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssPoweredByGrist = styled('div', `
|
||||||
|
color: ${colors.darkText};
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0px 10px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssPoweredByGristLink = styled('a', `
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: ${colors.darkText};
|
||||||
|
text-decoration: none;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssGristLogo = styled('div', `
|
||||||
|
width: 58px;
|
||||||
|
height: 20.416px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: url(img/logo-grist.png);
|
||||||
|
background-position: 0 0;
|
||||||
|
background-size: contain;
|
||||||
|
background-color: transparent;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
margin-top: 3px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssBuildForm = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssBuildFormLink = styled('a', `
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
text-decoration-line: underline;
|
||||||
|
color: ${colors.darkGreen};
|
||||||
|
--icon-color: ${colors.darkGreen};
|
||||||
|
`);
|
||||||
|
@ -1,139 +0,0 @@
|
|||||||
import {colors, mediaSmall} from 'app/client/ui2018/cssVars';
|
|
||||||
import {styled} from 'grainjs';
|
|
||||||
|
|
||||||
export const pageContainer = styled('div', `
|
|
||||||
background-color: ${colors.lightGrey};
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
padding: 52px 0px 52px 0px;
|
|
||||||
overflow: auto;
|
|
||||||
|
|
||||||
@media ${mediaSmall} {
|
|
||||||
& {
|
|
||||||
padding: 20px 0px 20px 0px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const formContainer = styled('div', `
|
|
||||||
padding-left: 16px;
|
|
||||||
padding-right: 16px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const form = styled('div', `
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
background-color: white;
|
|
||||||
border: 1px solid ${colors.darkGrey};
|
|
||||||
border-radius: 3px;
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0px auto;
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const formBody = styled('div', `
|
|
||||||
width: 100%;
|
|
||||||
padding: 20px 48px 20px 48px;
|
|
||||||
|
|
||||||
@media ${mediaSmall} {
|
|
||||||
& {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
const formMessageImageContainer = styled('div', `
|
|
||||||
margin-top: 28px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const formErrorMessageImageContainer = styled(formMessageImageContainer, `
|
|
||||||
height: 281px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const formSuccessMessageImageContainer = styled(formMessageImageContainer, `
|
|
||||||
height: 215px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const formMessageImage = styled('img', `
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const formErrorMessageImage = styled(formMessageImage, `
|
|
||||||
max-height: 281px;
|
|
||||||
max-width: 250px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const formSuccessMessageImage = styled(formMessageImage, `
|
|
||||||
max-height: 215px;
|
|
||||||
max-width: 250px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const formMessageText = styled('div', `
|
|
||||||
color: ${colors.dark};
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 24px;
|
|
||||||
margin-top: 32px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const formFooter = styled('div', `
|
|
||||||
border-top: 1px solid ${colors.darkGrey};
|
|
||||||
padding: 8px 16px;
|
|
||||||
width: 100%;
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const poweredByGrist = styled('div', `
|
|
||||||
color: ${colors.darkText};
|
|
||||||
font-size: 13px;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0px 10px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const poweredByGristLink = styled('a', `
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: ${colors.darkText};
|
|
||||||
text-decoration: none;
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const buildForm = styled('div', `
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 8px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const buildFormLink = styled('a', `
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 16px;
|
|
||||||
text-decoration-line: underline;
|
|
||||||
color: ${colors.darkGreen};
|
|
||||||
--icon-color: ${colors.darkGreen};
|
|
||||||
`);
|
|
||||||
|
|
||||||
export const gristLogo = styled('div', `
|
|
||||||
width: 58px;
|
|
||||||
height: 20.416px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: url(img/logo-grist.png);
|
|
||||||
background-position: 0 0;
|
|
||||||
background-size: contain;
|
|
||||||
background-color: transparent;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
margin-top: 3px;
|
|
||||||
`);
|
|
@ -0,0 +1,25 @@
|
|||||||
|
import {theme} from 'app/client/ui2018/cssVars';
|
||||||
|
import {styled} from 'grainjs';
|
||||||
|
|
||||||
|
export const cssRadioInput = styled('input', `
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: 0px !important;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-clip: content-box;
|
||||||
|
border: 1px solid ${theme.checkboxBorder};
|
||||||
|
background-color: ${theme.checkboxBg};
|
||||||
|
flex-shrink: 0;
|
||||||
|
&:hover {
|
||||||
|
border: 1px solid ${theme.checkboxBorderHover};
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
background-color: 1px solid ${theme.checkboxDisabledBg};
|
||||||
|
}
|
||||||
|
&:checked {
|
||||||
|
padding: 2px;
|
||||||
|
background-color: ${theme.controlPrimaryBg};
|
||||||
|
border: 1px solid ${theme.controlPrimaryBg};
|
||||||
|
}
|
||||||
|
`);
|
@ -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;
|
||||||
|
`);
|
Loading…
Reference in new issue