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 * as css from 'app/client/ui/FormPagesCss';
|
||||
import {colors, mediaSmall} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {DomContents, makeTestId} from 'grainjs';
|
||||
import {DomContents, DomElementArg, styled} from 'grainjs';
|
||||
|
||||
const t = makeT('FormContainer');
|
||||
|
||||
const testId = makeTestId('test-form-');
|
||||
|
||||
export function buildFormContainer(buildBody: () => DomContents) {
|
||||
return css.formContainer(
|
||||
css.form(
|
||||
css.formBody(
|
||||
export function buildFormMessagePage(buildBody: () => DomContents, ...args: DomElementArg[]) {
|
||||
return cssFormMessagePage(
|
||||
cssFormMessage(
|
||||
cssFormMessageBody(
|
||||
buildBody(),
|
||||
),
|
||||
css.formFooter(
|
||||
css.poweredByGrist(
|
||||
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'),
|
||||
),
|
||||
),
|
||||
cssFormMessageFooter(
|
||||
buildFormFooter(),
|
||||
),
|
||||
),
|
||||
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