mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) move client code to core
Summary: This moves all client code to core, and makes minimal fix-ups to get grist and grist-core to compile correctly. The client works in core, but I'm leaving clean-up around the build and bundles to follow-up. Test Plan: existing tests pass; server-dev bundle looks sane Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2627
This commit is contained in:
119
app/client/ui2018/breadcrumbs.ts
Normal file
119
app/client/ui2018/breadcrumbs.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Exports `docBreadcrumbs()` which returns a styled breadcrumb for the current page:
|
||||
*
|
||||
* [icon] Workspace (link) / Document name (editable) / Page name (editable)
|
||||
*
|
||||
* Workspace is a clickable link and document and page names are editable labels.
|
||||
*/
|
||||
import { urlState } from 'app/client/models/gristUrlState';
|
||||
import { colors, testId } from 'app/client/ui2018/cssVars';
|
||||
import { editableLabel } from 'app/client/ui2018/editableLabel';
|
||||
import { icon } from 'app/client/ui2018/icons';
|
||||
import { BindableValue, dom, Observable, styled } from 'grainjs';
|
||||
import { tooltip } from 'popweasel';
|
||||
|
||||
export const cssBreadcrumbs = styled('div', `
|
||||
color: ${colors.slate};
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
`);
|
||||
|
||||
export const cssBreadcrumbsLink = styled('a', `
|
||||
color: ${colors.lightGreen};
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`);
|
||||
|
||||
export const separator = styled('span', `
|
||||
padding: 0 2px;
|
||||
`);
|
||||
|
||||
const cssIcon = styled(icon, `
|
||||
background-color: ${colors.lightGreen};
|
||||
margin-top: -2px;
|
||||
`);
|
||||
|
||||
const cssPublicIcon = styled(cssIcon, `
|
||||
margin-left: 8px;
|
||||
margin-top: -4px;
|
||||
`);
|
||||
|
||||
const cssWorkspaceName = styled(cssBreadcrumbsLink, `
|
||||
margin-left: 8px;
|
||||
`);
|
||||
|
||||
const cssEditableName = styled('input', `
|
||||
&:hover, &:focus {
|
||||
color: ${colors.dark};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssTag = styled('span', `
|
||||
background-color: ${colors.slate};
|
||||
color: white;
|
||||
border-radius: 3px;
|
||||
padding: 0 4px;
|
||||
margin-left: 4px;
|
||||
`);
|
||||
|
||||
interface PartialWorkspace {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const fiddleExplanation = (
|
||||
'You may make edits, but they will create a new copy and will\n' +
|
||||
'not affect the original document.'
|
||||
);
|
||||
|
||||
export function docBreadcrumbs(
|
||||
workspace: Observable<PartialWorkspace|null>,
|
||||
docName: Observable<string>,
|
||||
pageName: Observable<string>,
|
||||
options: {
|
||||
docNameSave: (val: string) => Promise<void>,
|
||||
pageNameSave: (val: string) => Promise<void>,
|
||||
isDocNameReadOnly?: BindableValue<boolean>,
|
||||
isPageNameReadOnly?: BindableValue<boolean>,
|
||||
isFork: Observable<boolean>,
|
||||
isFiddle: Observable<boolean>,
|
||||
isSnapshot?: Observable<boolean>,
|
||||
isPublic?: Observable<boolean>,
|
||||
}
|
||||
): Element {
|
||||
return cssBreadcrumbs(
|
||||
cssIcon('Home'),
|
||||
dom.maybe(workspace, _workspace => [
|
||||
cssWorkspaceName(
|
||||
urlState().setLinkUrl({ws: _workspace.id}),
|
||||
dom.text(_workspace.name),
|
||||
testId('bc-workspace')
|
||||
),
|
||||
separator(' / ')
|
||||
]),
|
||||
editableLabel(
|
||||
docName, options.docNameSave, testId('bc-doc'), cssEditableName.cls(''),
|
||||
dom.boolAttr('disabled', options.isDocNameReadOnly || false),
|
||||
),
|
||||
dom.maybe(options.isPublic, () => cssPublicIcon('PublicFilled', testId('bc-is-public'))),
|
||||
dom.domComputed((use) => {
|
||||
if (options.isSnapshot && use(options.isSnapshot)) {
|
||||
return cssTag('snapshot', testId('snapshot-tag'));
|
||||
}
|
||||
if (use(options.isFork)) {
|
||||
return cssTag('unsaved', testId('unsaved-tag'));
|
||||
}
|
||||
if (use(options.isFiddle)) {
|
||||
return cssTag('fiddle', tooltip({title: fiddleExplanation}), testId('fiddle-tag'));
|
||||
}
|
||||
}),
|
||||
separator(' / '),
|
||||
editableLabel(
|
||||
pageName, options.pageNameSave, testId('bc-page'), cssEditableName.cls(''),
|
||||
dom.boolAttr('disabled', options.isPageNameReadOnly || false),
|
||||
),
|
||||
);
|
||||
}
|
||||
264
app/client/ui2018/buttonSelect.ts
Normal file
264
app/client/ui2018/buttonSelect.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
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};
|
||||
}
|
||||
`);
|
||||
127
app/client/ui2018/buttons.ts
Normal file
127
app/client/ui2018/buttons.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* UI 2018 Buttons
|
||||
*
|
||||
* Four styles are include: basicButton, primaryButton, bigBasicButton, bigPrimaryButton.
|
||||
*
|
||||
* Buttons support passing in DomElementArgs, which can be used to register click handlers, set
|
||||
* the disabled property, and other HTML <button> element behaviors.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* `basicButton('Basic button', dom.on('click', () => alert('Basic button')))`
|
||||
* `primaryButton('Primary button', dom.prop('disabled', true))`
|
||||
*/
|
||||
|
||||
import { colors, vars } from 'app/client/ui2018/cssVars';
|
||||
import { tbind } from 'app/common/tbind';
|
||||
import { dom, DomElementArg, styled } from 'grainjs';
|
||||
|
||||
export const cssButton = styled('button', `
|
||||
/* Resets */
|
||||
position: relative;
|
||||
outline: none;
|
||||
border-style: none;
|
||||
line-height: normal;
|
||||
|
||||
/* Vars */
|
||||
font-size: ${vars.mediumFontSize};
|
||||
letter-spacing: -0.08px;
|
||||
padding: 4px 8px;
|
||||
|
||||
background-color: transparent;
|
||||
color: ${vars.controlFg};
|
||||
--icon-color: ${vars.controlFg};
|
||||
|
||||
border: ${vars.controlBorder};
|
||||
border-radius: ${vars.controlBorderRadius};
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&-large {
|
||||
font-weight: 500;
|
||||
padding: 12px 24px;
|
||||
}
|
||||
|
||||
&-primary {
|
||||
background-color: ${vars.primaryBg};
|
||||
color: ${vars.primaryFg};
|
||||
--icon-color: ${vars.primaryFg};
|
||||
border-color: ${vars.primaryBg};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${vars.controlFgHover};
|
||||
--icon-color: ${vars.controlFgHover};
|
||||
border-color: ${vars.controlFgHover};
|
||||
}
|
||||
&-primary:hover {
|
||||
color: ${vars.primaryFg};
|
||||
--icon-color: ${vars.primaryFg};
|
||||
background-color: ${vars.primaryBgHover};
|
||||
border-color: ${vars.primaryBgHover};
|
||||
}
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
color: ${colors.light};
|
||||
--icon-color: ${colors.light};
|
||||
background-color: ${colors.slate};
|
||||
border-color: ${colors.slate};
|
||||
}
|
||||
|
||||
`);
|
||||
|
||||
interface IButtonProps {
|
||||
large?: boolean;
|
||||
primary?: boolean;
|
||||
link?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a button or button-like link with requested properties.
|
||||
*/
|
||||
function button(props: IButtonProps, ...domArgs: DomElementArg[]) {
|
||||
const elem = props.link ? cssButtonLink(dom.cls(cssButton.className)) : cssButton();
|
||||
return dom.update(elem,
|
||||
cssButton.cls('-large', Boolean(props.large)),
|
||||
cssButton.cls('-primary', Boolean(props.primary)),
|
||||
...domArgs
|
||||
);
|
||||
}
|
||||
|
||||
// Button-creating functions, each taking ...DomElementArg arguments.
|
||||
export const basicButton = tbind(button, null, {});
|
||||
export const bigBasicButton = tbind(button, null, {large: true});
|
||||
export const primaryButton = tbind(button, null, {primary: true});
|
||||
export const bigPrimaryButton = tbind(button, null, {large: true, primary: true});
|
||||
|
||||
// Functions that create button-like <a> links, each taking ...DomElementArg arguments.
|
||||
export const basicButtonLink = tbind(button, null, {link: true});
|
||||
export const bigBasicButtonLink = tbind(button, null, {link: true, large: true});
|
||||
export const primaryButtonLink = tbind(button, null, {link: true, primary: true});
|
||||
export const bigPrimaryButtonLink = tbind(button, null, {link: true, large: true, primary: true});
|
||||
|
||||
const cssButtonLink = styled('a', `
|
||||
display: inline-block;
|
||||
&, &:hover, &:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssButtonGroup = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
& > .${cssButton.className} {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
& > .${cssButton.className}:first-child {
|
||||
border-top-left-radius: ${vars.controlBorderRadius};
|
||||
border-bottom-left-radius: ${vars.controlBorderRadius};
|
||||
}
|
||||
|
||||
& > .${cssButton.className}:last-child {
|
||||
border-top-right-radius: ${vars.controlBorderRadius};
|
||||
border-bottom-right-radius: ${vars.controlBorderRadius};
|
||||
}
|
||||
`);
|
||||
145
app/client/ui2018/checkbox.ts
Normal file
145
app/client/ui2018/checkbox.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* UI 2018 Checkboxes
|
||||
*
|
||||
* Includes:
|
||||
* - squareCheckbox
|
||||
* - circleCheckbox
|
||||
* - labeledSquareCheckbox
|
||||
* - labeledCircleCheckbox
|
||||
*
|
||||
* Checkboxes support passing in DomElementArgs, which can be used to register click handlers, set
|
||||
* the disabled property, and other HTML <input> element behaviors.
|
||||
*
|
||||
* Examples:
|
||||
* squareCheckbox(observable(true)),
|
||||
* labeledSquareCheckbox(observable(false), 'Include other values', dom.prop('disabled', true)),
|
||||
*/
|
||||
|
||||
import { colors } from 'app/client/ui2018/cssVars';
|
||||
import { dom, DomArg, styled } from 'grainjs';
|
||||
import { Observable } from 'grainjs';
|
||||
|
||||
export const cssLabel = styled('label', `
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
min-width: 0px;
|
||||
margin-bottom: 0px;
|
||||
|
||||
outline: none;
|
||||
user-select: none;
|
||||
|
||||
--color: ${colors.darkGrey};
|
||||
&:hover {
|
||||
--color: ${colors.hover};
|
||||
}
|
||||
`);
|
||||
|
||||
// TODO: the !important markings are to trump bootstrap, and should be removed when it's gone.
|
||||
export const cssCheckboxSquare = styled('input', `
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
margin: 0 !important;
|
||||
padding: 0;
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
outline: none !important;
|
||||
|
||||
--radius: 3px;
|
||||
|
||||
&:checked:enabled {
|
||||
--color: ${colors.lightGreen};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
--color: ${colors.darkGrey};
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.${cssLabel.className}:hover > &:checked:enabled {
|
||||
--color: ${colors.darkGreen};
|
||||
}
|
||||
|
||||
|
||||
&::before, &::after {
|
||||
content: '';
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--color);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
&:checked::before, &:disabled::before {
|
||||
background-color: var(--color);
|
||||
}
|
||||
|
||||
&:checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
-webkit-mask-image: var(--icon-Tick);
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-position: center;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
background-color: ${colors.light};
|
||||
}
|
||||
|
||||
&:not(:disabled)::after {
|
||||
background-color: ${colors.light};
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssCheckboxCircle = styled(cssCheckboxSquare, `
|
||||
--radius: 100%;
|
||||
`);
|
||||
|
||||
export const cssLabelText = styled('span', `
|
||||
margin-left: 8px;
|
||||
color: ${colors.dark};
|
||||
font-weight: initial; /* negate bootstrap */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`);
|
||||
|
||||
type CheckboxArg = DomArg<HTMLInputElement>;
|
||||
|
||||
function checkbox(
|
||||
obs: Observable<boolean>, cssCheckbox: typeof cssCheckboxSquare, label: string = '', ...domArgs: CheckboxArg[]
|
||||
) {
|
||||
return cssLabel(
|
||||
cssCheckbox(
|
||||
{ type: 'checkbox' },
|
||||
dom.prop('checked', obs),
|
||||
dom.on('change', (ev, el) => obs.set(el.checked)),
|
||||
...domArgs
|
||||
),
|
||||
label ? cssLabelText(label) : null
|
||||
);
|
||||
}
|
||||
|
||||
export function squareCheckbox(obs: Observable<boolean>, ...domArgs: CheckboxArg[]) {
|
||||
return checkbox(obs, cssCheckboxSquare, '', ...domArgs);
|
||||
}
|
||||
|
||||
export function circleCheckbox(obs: Observable<boolean>, ...domArgs: CheckboxArg[]) {
|
||||
return checkbox(obs, cssCheckboxCircle, '', ...domArgs);
|
||||
}
|
||||
|
||||
export function labeledSquareCheckbox(obs: Observable<boolean>, label: string, ...domArgs: CheckboxArg[]) {
|
||||
return checkbox(obs, cssCheckboxSquare, label, ...domArgs);
|
||||
}
|
||||
|
||||
export function labeledCircleCheckbox(obs: Observable<boolean>, label: string, ...domArgs: CheckboxArg[]) {
|
||||
return checkbox(obs, cssCheckboxCircle, label, ...domArgs);
|
||||
}
|
||||
15
app/client/ui2018/draggableList.ts
Normal file
15
app/client/ui2018/draggableList.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import {testId} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {styled} from 'grainjs';
|
||||
|
||||
// TODO: Update and move koForm draggableList here.
|
||||
|
||||
// Drag icon for use in koForm draggableList.
|
||||
export const cssDragger = styled((...args: any[]) => icon('DragDrop', testId('dragger'), ...args), `
|
||||
visibility: hidden;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
.kf_draggable:hover & {
|
||||
visibility: visible;
|
||||
}
|
||||
`);
|
||||
149
app/client/ui2018/editableLabel.ts
Normal file
149
app/client/ui2018/editableLabel.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* editableLabel uses grainjs's input widget and adds UI and behavioral extensions:
|
||||
* - Label width grows/shrinks with content (using a hidden sizer element)
|
||||
* - On Escape, cancel editing and revert to original value
|
||||
* - Clicking away or hitting Enter on empty value cancels editing too
|
||||
*
|
||||
* The structure is a wrapper diver with an input child: div > input. Supports passing in
|
||||
* DomElementArgs, which get passed to the underlying <input> element.
|
||||
*
|
||||
* TODO: Consider merging this into grainjs's input widget.
|
||||
*/
|
||||
import { colors } from 'app/client/ui2018/cssVars';
|
||||
import { dom, DomElementArg, styled } from 'grainjs';
|
||||
import { Observable } from 'grainjs';
|
||||
import noop = require('lodash/noop');
|
||||
|
||||
const cssWrapper = styled('div', `
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
`);
|
||||
|
||||
export const cssLabelText = styled(rawTextInput, `
|
||||
/* Reset apperance */
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
/* Size is determined by the hidden sizer, so take up 100% of width */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
|
||||
line-height: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
background-color: inherit;
|
||||
color: inherit;
|
||||
`);
|
||||
|
||||
export const cssTextInput = styled('input', `
|
||||
outline: none;
|
||||
height: 28px;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
border-radius: 3px;
|
||||
padding: 0 6px;
|
||||
`);
|
||||
|
||||
const cssSizer = styled('div', `
|
||||
visibility: hidden;
|
||||
overflow: visible;
|
||||
white-space: pre;
|
||||
|
||||
&:empty:before {
|
||||
content: ' '; /* Don't collapse */
|
||||
}
|
||||
`);
|
||||
|
||||
enum Status { NORMAL, EDITING, SAVING }
|
||||
|
||||
type SaveFunc = (value: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Provides a label that takes in an observable that is set on Enter or loss of focus. Escape
|
||||
* cancels editing. Label grows in size with typed input. Validation logic (if any) should happen in
|
||||
* the save function, to reject a value simply throw an error, this will revert to the saved one .
|
||||
*/
|
||||
export function editableLabel(label: Observable<string>, save: SaveFunc, ...args: DomElementArg[]) {
|
||||
let input: HTMLInputElement;
|
||||
let sizer: HTMLSpanElement;
|
||||
|
||||
function updateSizer() {
|
||||
sizer.textContent = input.value;
|
||||
}
|
||||
|
||||
return cssWrapper(
|
||||
sizer = cssSizer(label.get()),
|
||||
input = rawTextInput(label, save, updateSizer, dom.cls(cssLabelText.className),
|
||||
dom.on('focus', () => input.select()),
|
||||
...args
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a text input element that pretty much behaves like the editableLabel only it shows as a
|
||||
* regular input within a rigid static frame. It takes in an observable that is setf on Enter or loss
|
||||
* of focus. Escape cancels editing. Validation logic (if any) should happen in the save function,
|
||||
* to reject a value simply throw an error, this will revert to the the saved one.
|
||||
*/
|
||||
export function textInput(label: Observable<string>, save: SaveFunc, ...args: DomElementArg[]) {
|
||||
return rawTextInput(label, save, noop, dom.cls(cssTextInput.className), ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper that implements all the saving logic for both editableLabel and textInput.
|
||||
*/
|
||||
export function rawTextInput(value: Observable<string>, save: SaveFunc, onChange: () => void,
|
||||
...args: DomElementArg[]) {
|
||||
let status: Status = Status.NORMAL;
|
||||
let inputEl: HTMLInputElement;
|
||||
|
||||
// When label changes updates the input, unless in the middle of editing.
|
||||
const lis = value.addListener((val) => { if (status !== Status.EDITING) { setValue(val); }});
|
||||
|
||||
function setValue(val: string) {
|
||||
inputEl.value = val;
|
||||
onChange();
|
||||
}
|
||||
|
||||
function revertToSaved() {
|
||||
setValue(value.get());
|
||||
status = Status.NORMAL;
|
||||
inputEl.blur();
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (status === Status.EDITING) {
|
||||
status = Status.SAVING;
|
||||
inputEl.disabled = true;
|
||||
// Ignore errors; save() callback is expected to handle their reporting.
|
||||
try { await save(inputEl.value); } catch (e) { /* ignore */ }
|
||||
inputEl.disabled = false;
|
||||
revertToSaved();
|
||||
} else if (status === Status.NORMAL) {
|
||||
// If we are not editing, nothing to save, but lets end in the expected blurred state.
|
||||
inputEl.blur();
|
||||
}
|
||||
}
|
||||
|
||||
return inputEl = dom('input',
|
||||
dom.autoDispose(lis),
|
||||
{type: 'text'},
|
||||
dom.on('input', () => { status = Status.EDITING; onChange(); }),
|
||||
dom.on('blur', saveEdit),
|
||||
// we set the attribute to the initial value and keep it updated for the convenience of usage
|
||||
// with selenium webdriver
|
||||
dom.attr('value', value),
|
||||
dom.onKeyDown({
|
||||
Escape: revertToSaved,
|
||||
Enter: saveEdit,
|
||||
}),
|
||||
...args
|
||||
);
|
||||
}
|
||||
18
app/client/ui2018/links.ts
Normal file
18
app/client/ui2018/links.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Styling for a simple green <A HREF> link.
|
||||
*/
|
||||
|
||||
import { colors } from 'app/client/ui2018/cssVars';
|
||||
import { styled } from 'grainjs';
|
||||
|
||||
// Match the font-weight of buttons.
|
||||
export const cssLink = styled('a', `
|
||||
color: ${colors.lightGreen};
|
||||
--icon-color: ${colors.lightGreen};
|
||||
text-decoration: none;
|
||||
&:hover, &:focus {
|
||||
color: ${colors.lightGreen};
|
||||
--icon-color: ${colors.lightGreen};
|
||||
text-decoration: underline;
|
||||
}
|
||||
`);
|
||||
22
app/client/ui2018/loaders.ts
Normal file
22
app/client/ui2018/loaders.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
import {keyframes, styled} from 'grainjs';
|
||||
|
||||
const rotate360 = keyframes(`
|
||||
from { transform: rotate(45deg); }
|
||||
75% { transform: rotate(405deg); }
|
||||
to { transform: rotate(405deg); }
|
||||
`);
|
||||
|
||||
/**
|
||||
* Creates a 32x32 pixel loading spinner. Use by calling `loadingSpinner()`.
|
||||
*/
|
||||
export const loadingSpinner = styled('div', `
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 32px;
|
||||
border: 4px solid ${colors.darkGrey};
|
||||
border-top-color: ${colors.lightGreen};
|
||||
animation: ${rotate360} 1s ease-out infinite;
|
||||
`);
|
||||
360
app/client/ui2018/menus.ts
Normal file
360
app/client/ui2018/menus.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import {Command} from 'app/client/components/commands';
|
||||
import {NeedUpgradeError, reportError} from 'app/client/models/errors';
|
||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||
import {IconName} from 'app/client/ui2018/IconList';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {commonUrls} from 'app/common/gristUrls';
|
||||
import {dom, DomElementArg, DomElementMethod} from 'grainjs';
|
||||
import {MaybeObsArray, Observable, styled} from 'grainjs';
|
||||
import * as weasel from 'popweasel';
|
||||
import {IAutocompleteOptions} from 'popweasel';
|
||||
|
||||
export interface IOptionFull<T> {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
icon?: IconName;
|
||||
}
|
||||
|
||||
// For string options, we can use a string for label and value without wrapping into an object.
|
||||
export type IOption<T> = (T & string) | IOptionFull<T>;
|
||||
|
||||
export function menu(createFunc: weasel.MenuCreateFunc, options?: weasel.IMenuOptions): DomElementMethod {
|
||||
return weasel.menu(createFunc, {...defaults, ...options});
|
||||
}
|
||||
|
||||
// TODO Weasel doesn't allow other options for submenus, but probably should.
|
||||
export type ISubMenuOptions = weasel.ISubMenuOptions & weasel.IPopupOptions;
|
||||
|
||||
export function menuItemSubmenu(
|
||||
submenu: weasel.MenuCreateFunc,
|
||||
options: ISubMenuOptions,
|
||||
...args: DomElementArg[]
|
||||
): Element {
|
||||
return weasel.menuItemSubmenu(submenu, {...defaults, ...options}, ...args);
|
||||
}
|
||||
|
||||
const cssMenuElem = styled('div', `
|
||||
font-family: ${vars.fontFamily};
|
||||
font-size: ${vars.mediumFontSize};
|
||||
line-height: initial;
|
||||
max-width: 400px;
|
||||
padding: 8px 0px 16px 0px;
|
||||
box-shadow: 0 2px 20px 0 rgba(38,38,51,0.6);
|
||||
min-width: 160px;
|
||||
z-index: 999;
|
||||
--weaseljs-selected-background-color: ${vars.primaryBg};
|
||||
--weaseljs-menu-item-padding: 8px 24px;
|
||||
`);
|
||||
|
||||
const menuItemStyle = `
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
--icon-color: ${colors.lightGreen};
|
||||
.${weasel.cssMenuItem.className}-sel {
|
||||
--icon-color: ${colors.light};
|
||||
}
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.2;
|
||||
}
|
||||
`;
|
||||
|
||||
export const menuCssClass = cssMenuElem.className;
|
||||
|
||||
// Add grist-floating-menu class to support existing browser tests
|
||||
const defaults = { menuCssClass: menuCssClass + ' grist-floating-menu' };
|
||||
|
||||
/**
|
||||
* Creates a select dropdown widget. The observable `obs` reflects the value of the selected
|
||||
* option, and `optionArray` is an array (regular or observable) of option values and labels.
|
||||
* These may be either strings, or {label, value, icon, disabled} objects. Icons are optional
|
||||
* and must be IconName strings from 'app/client/ui2018/IconList'.
|
||||
*
|
||||
* The type of value may be any type at all; it is opaque to this widget.
|
||||
*
|
||||
* If obs is set to an invalid or disabled value, then defLabel option is used to determine the
|
||||
* label that the select box will show, blank by default.
|
||||
*
|
||||
* Usage:
|
||||
* const fruit = observable("apple");
|
||||
* select(fruit, ["apple", "banana", "mango"]);
|
||||
*
|
||||
* const employee = observable(17);
|
||||
* const allEmployees = Observable.create(owner, [
|
||||
* {value: 12, label: "Bob", disabled: true},
|
||||
* {value: 17, label: "Alice"},
|
||||
* {value: 21, label: "Eve"},
|
||||
* ]);
|
||||
* select(employee, allEmployees, {defLabel: "Select employee:"});
|
||||
*
|
||||
* Note that this select element is not compatible with browser address autofill for usage in
|
||||
* forms, and that formSelect should be used for this purpose.
|
||||
*/
|
||||
export function select<T>(obs: Observable<T>, optionArray: MaybeObsArray<IOption<T>>,
|
||||
options: weasel.ISelectUserOptions = {}) {
|
||||
const _menu = cssSelectMenuElem(testId('select-menu'));
|
||||
const _btn = cssSelectBtn(testId('select-open'));
|
||||
|
||||
const selectOptions = {
|
||||
buttonArrow: cssInlineCollapseIcon('Collapse'),
|
||||
menuCssClass: _menu.className,
|
||||
buttonCssClass: _btn.className,
|
||||
...options,
|
||||
};
|
||||
|
||||
return weasel.select(obs, optionArray, selectOptions, (op) =>
|
||||
cssOptionRow(
|
||||
op.icon ? cssOptionRowIcon(op.icon) : null,
|
||||
cssOptionLabel(op.label),
|
||||
testId('select-row')
|
||||
)
|
||||
) as HTMLElement; // TODO: should be changed in weasel
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as select(), but the main element looks like a link rather than a button.
|
||||
*/
|
||||
export function linkSelect<T>(obs: Observable<T>, optionArray: MaybeObsArray<IOption<T>>,
|
||||
options: weasel.ISelectUserOptions = {}) {
|
||||
const _btn = cssSelectBtnLink(testId('select-open'));
|
||||
const elem = select(obs, optionArray, {buttonCssClass: _btn.className, ...options});
|
||||
// It feels strange to have focus stay on this link; remove tabIndex that makes it focusable.
|
||||
elem.removeAttribute('tabIndex');
|
||||
return elem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a select dropdown widget that is more ideal for forms. Implemented using the <select>
|
||||
* element to work with browser form autofill and typing in the desired value to quickly set it.
|
||||
* The appearance of the opened menu is OS dependent.
|
||||
*
|
||||
* The observable `obs` reflects the value of the selected option, and `optionArray` is an
|
||||
* array (regular or observable) of option values and labels. These may be either strings,
|
||||
* or {label, value} objects.
|
||||
*
|
||||
* If obs is set to an empty string value, then defLabel option is used to determine the
|
||||
* label that the select box will show, blank by default.
|
||||
*
|
||||
* Usage:
|
||||
* const fruit = observable("");
|
||||
* formSelect(fruit, ["apple", "banana", "mango"], {defLabel: "Select fruit:"});
|
||||
*/
|
||||
export function formSelect(obs: Observable<string>, optionArray: MaybeObsArray<IOption<string>>,
|
||||
options: {defaultLabel?: string} = {}) {
|
||||
const {defaultLabel = ""} = options;
|
||||
const container: Element = cssSelectBtnContainer(
|
||||
dom('select', {class: cssSelectBtn.className, style: 'height: 42px; padding: 12px 30px 12px 12px;'},
|
||||
dom.prop('value', obs),
|
||||
dom.on('change', (_, elem) => { obs.set(elem.value); }),
|
||||
dom('option', {value: '', hidden: 'hidden'}, defaultLabel),
|
||||
dom.forEach(optionArray, (option) => {
|
||||
const obj: weasel.IOptionFull<string> = weasel.getOptionFull(option);
|
||||
return dom('option', {value: obj.value}, obj.label);
|
||||
})
|
||||
),
|
||||
cssCollapseIcon('Collapse')
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
export function inputMenu(createFunc: weasel.MenuCreateFunc, options?: weasel.IMenuOptions): DomElementMethod {
|
||||
// Triggers the input menu on 'input' events, if the input has text inside.
|
||||
function inputTrigger(triggerElem: Element, ctl: weasel.PopupControl): void {
|
||||
dom.onElem(triggerElem, 'input', () => {
|
||||
(triggerElem as HTMLInputElement).value.length > 0 ? ctl.open() : ctl.close();
|
||||
});
|
||||
}
|
||||
return weasel.inputMenu(createFunc, {
|
||||
trigger: [inputTrigger],
|
||||
menuCssClass: `${cssMenuElem.className} ${cssInputButtonMenuElem.className}`,
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
// A menu item that leads to the billing page if the desired operation requires an upgrade.
|
||||
// Such menu items are marked with a little sparkle unicode.
|
||||
export function upgradableMenuItem(needUpgrade: boolean, action: () => void, ...rem: any[]) {
|
||||
if (needUpgrade) {
|
||||
return menuItem(() => reportError(new NeedUpgradeError()), ...rem, " *");
|
||||
} else {
|
||||
return menuItem(action, ...rem);
|
||||
}
|
||||
}
|
||||
|
||||
export function upgradeText(needUpgrade: boolean) {
|
||||
if (!needUpgrade) { return null; }
|
||||
return menuText(dom('span', '* Workspaces are available on team plans. ',
|
||||
dom('a', {href: commonUrls.plans}, 'Upgrade now')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an autocomplete element and tie it to an input or textarea element.
|
||||
*
|
||||
* Usage:
|
||||
* const employees = ['Thomas', 'June', 'Bethany', 'Mark', 'Marjorey', 'Zachary'];
|
||||
* const inputElem = input(...);
|
||||
* autocomplete(inputElem, employees);
|
||||
*/
|
||||
export function autocomplete(
|
||||
inputElem: HTMLInputElement,
|
||||
choices: MaybeObsArray<string>,
|
||||
options: IAutocompleteOptions = {}
|
||||
) {
|
||||
return weasel.autocomplete(inputElem, choices, {
|
||||
...defaults, ...options,
|
||||
menuCssClass: menuCssClass + ' ' + cssSelectMenuElem.className,
|
||||
});
|
||||
}
|
||||
|
||||
export const menuSubHeader = styled('div', `
|
||||
font-size: ${vars.xsmallFontSize};
|
||||
text-transform: uppercase;
|
||||
font-weight: ${vars.bigControlTextWeight};
|
||||
padding: 8px 24px 16px 24px;
|
||||
cursor: default;
|
||||
`);
|
||||
|
||||
export const menuText = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: ${vars.smallFontSize};
|
||||
color: ${colors.slate};
|
||||
padding: 8px 24px 4px 24px;
|
||||
max-width: 250px;
|
||||
cursor: default;
|
||||
`);
|
||||
|
||||
export const menuItem = styled(weasel.menuItem, menuItemStyle);
|
||||
|
||||
export const menuItemLink = styled(weasel.menuItemLink, menuItemStyle);
|
||||
|
||||
export function menuItemCmd(cmd: Command, label: string, ...args: DomElementArg[]) {
|
||||
return menuItem(
|
||||
cmd.run,
|
||||
dom('span', label, testId('cmd-name')),
|
||||
cmd.humanKeys.length ? cssCmdKey(cmd.humanKeys[0]) : null,
|
||||
cssMenuItemCmd.cls(''), // overrides some menu item styles
|
||||
...args
|
||||
);
|
||||
}
|
||||
|
||||
export const menuDivider = styled(weasel.cssMenuDivider, `
|
||||
margin: 8px 0;
|
||||
`);
|
||||
|
||||
export const menuIcon = styled(icon, `
|
||||
flex: none;
|
||||
margin-right: 8px;
|
||||
`);
|
||||
|
||||
const cssSelectMenuElem = styled(cssMenuElem, `
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
||||
--weaseljs-menu-item-padding: 8px 16px;
|
||||
`);
|
||||
|
||||
const cssSelectBtnContainer = styled('div', `
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`);
|
||||
|
||||
const cssSelectBtn = styled('div', `
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
line-height: 16px;
|
||||
background-color: white;
|
||||
font-size: ${vars.mediumFontSize};
|
||||
padding: 5px;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
color: ${colors.dark};
|
||||
--icon-color: ${colors.dark};
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
display: flex;
|
||||
`);
|
||||
|
||||
const cssSelectBtnLink = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: ${vars.mediumFontSize};
|
||||
color: ${colors.lightGreen};
|
||||
--icon-color: ${colors.lightGreen};
|
||||
width: initial;
|
||||
height: initial;
|
||||
line-height: inherit;
|
||||
background-color: initial;
|
||||
padding: initial;
|
||||
border: initial;
|
||||
border-radius: initial;
|
||||
box-shadow: initial;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
|
||||
&:hover, &:focus, &:active {
|
||||
color: ${colors.darkGreen};
|
||||
--icon-color: ${colors.darkGreen};
|
||||
box-shadow: initial;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssOptionIcon = styled(icon, `
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
background-color: ${colors.slate};
|
||||
margin: -3px 8px 0 2px;
|
||||
`);
|
||||
|
||||
const cssOptionRow = styled('span', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
`);
|
||||
|
||||
const cssOptionRowIcon = styled(cssOptionIcon, `
|
||||
margin: 0 8px 0 0;
|
||||
flex: none;
|
||||
|
||||
.${weasel.cssMenuItem.className}-sel & {
|
||||
background-color: white;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssOptionLabel = styled('div', `
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`);
|
||||
|
||||
const cssInlineCollapseIcon = styled(icon, `
|
||||
margin: 0 2px;
|
||||
pointer-events: none;
|
||||
`);
|
||||
|
||||
const cssCollapseIcon = styled(icon, `
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: calc(50% - 8px);
|
||||
pointer-events: none;
|
||||
background-color: ${colors.dark};
|
||||
`);
|
||||
|
||||
const cssInputButtonMenuElem = styled(cssMenuElem, `
|
||||
padding: 4px 0px;
|
||||
`);
|
||||
|
||||
const cssMenuItemCmd = styled('div', `
|
||||
justify-content: space-between;
|
||||
`);
|
||||
|
||||
const cssCmdKey = styled('span', `
|
||||
margin-left: 16px;
|
||||
color: ${colors.slate};
|
||||
margin-right: -12px;
|
||||
`);
|
||||
292
app/client/ui2018/modals.ts
Normal file
292
app/client/ui2018/modals.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
import {Computed, dom, DomElementArg, MultiHolder, Observable, styled} from 'grainjs';
|
||||
|
||||
export interface IModalControl {
|
||||
close(): void;
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
export interface IModalOptions {
|
||||
noEscapeKey?: boolean; // If set, escape key does not close the dialog
|
||||
noClickAway?: boolean; // If set, clicking into background does not close dialog.
|
||||
}
|
||||
|
||||
// A custom error type to signal to the modal that it should stay open, but not report any error
|
||||
// (presumably because the error was already reported).
|
||||
export class StayOpen extends Error {
|
||||
}
|
||||
|
||||
export type ModalWidth =
|
||||
'normal' | // Normal dialog, from 428px to 480px in width.
|
||||
'fixed-wide'; // Fixed 600px width.
|
||||
|
||||
/**
|
||||
* A simple modal. Shows up in the middle of the screen with a tinted backdrop.
|
||||
* Created with the given body content and width.
|
||||
*
|
||||
* Closed via clicking anywhere outside the modal. May also be closed by
|
||||
* calling ctl.close().
|
||||
*
|
||||
* The createFn callback may tie the disposal of temporary objects to its `owner` argument.
|
||||
*
|
||||
* Example usage:
|
||||
* modal((ctl, owner) => [
|
||||
* cssModalTitle(`Pin doc`),
|
||||
* cssModalBody('Are you sure you want to pin doc?')
|
||||
* cssModalButtons(
|
||||
* primary('Yes', dom.on('click', () => { onClick(true); ctl.close(); })),
|
||||
* secondary('Cancel', dom.on('click', () => { onClick(false); ctl.close(); }))
|
||||
* )
|
||||
* ])
|
||||
*/
|
||||
export function modal(createFn: (ctl: IModalControl, owner: MultiHolder) => DomElementArg,
|
||||
options: IModalOptions = {}): void {
|
||||
function close() {
|
||||
document.body.removeChild(modalDom);
|
||||
// Ensure we run the disposers for the DOM contained in the modal.
|
||||
dom.domDispose(modalDom);
|
||||
}
|
||||
|
||||
const modalDom = cssModalBacker(
|
||||
dom.create((owner) => {
|
||||
const focus = () => dialog.focus();
|
||||
const dialog = cssModalDialog(
|
||||
createFn({ close, focus }, owner),
|
||||
dom.on('click', (ev) => ev.stopPropagation()),
|
||||
options.noEscapeKey ? null : dom.onKeyDown({ Escape: close }),
|
||||
// Focus the dialog to allow it to receive keyboard events.
|
||||
// When triggered by a weasel menu, the menu grabs restores focus after getting closed to the
|
||||
// element focused before it was opened. This interferes with focusing the modal, so we need to
|
||||
// wait a bit and focus later. TODO: Weasel menus should stop creating problems with focus.
|
||||
(elem) => { setTimeout(() => elem.focus(), 10); },
|
||||
testId('modal-dialog')
|
||||
);
|
||||
return dialog;
|
||||
}),
|
||||
options.noClickAway ? null : dom.on('click', close),
|
||||
);
|
||||
|
||||
|
||||
document.body.appendChild(modalDom);
|
||||
}
|
||||
|
||||
export interface ISaveModalOptions {
|
||||
title: DomElementArg; // Normally just a string.
|
||||
body: DomElementArg; // Content of the dialog.
|
||||
saveLabel?: DomElementArg; // Normally just a string; defaults to "Save".
|
||||
saveDisabled?: Observable<boolean>; // Optional observable for when to disable Save button.
|
||||
saveFunc: () => Promise<unknown>; // Called on Save; dialog closes when promise is fulfilled.
|
||||
hideCancel?: boolean; // If set, hide the Cancel button
|
||||
width?: ModalWidth; // Set a width style for the dialog.
|
||||
modalArgs?: DomElementArg; // Extra args to apply to the outer cssModalDialog element.
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a modal dialog with a title, body, and Save/Cancel buttons. The provided createFunc()
|
||||
* is called immediately to get the dialog's contents and options (see ISaveModalOptions for
|
||||
* details). For example:
|
||||
*
|
||||
* saveModal((ctl, owner) => {
|
||||
* const myObs = Computed.create(owner, ...);
|
||||
* return {
|
||||
* title: 'My Dialog',
|
||||
* body: dom('div', 'Hello', dom.text(myObs)),
|
||||
* saveDisabled: Computed.create(owner, (use) => !use(myObs)),
|
||||
* saveFunc: () => server.ping(),
|
||||
* modalArgs: {style: 'background-color: blue'},
|
||||
* };
|
||||
* });
|
||||
*
|
||||
* On Save, the dialog calls saveFunc(), disables the Save button, and stays open until saveFunc()
|
||||
* is resolved. It then closes on success, or reports the error and stays open on rejection. To
|
||||
* stay open without reporting an error (if one is already reported), throw StayOpen exception.
|
||||
*
|
||||
* The dialog interprets Enter/Escape keys as if the Save/Cancel buttons were clicked.
|
||||
*
|
||||
* Note that it's possible to close the dialog via Cancel while saveFunc() is pending. That's
|
||||
* probably desirable, but keep in mind that the dialog may be disposed before saveFunc() returns.
|
||||
*
|
||||
* Error handling examples:
|
||||
* 1. saveFunc: doSomething
|
||||
* (Most common) If doSomething fails, the error is reported and the dialog stays open.
|
||||
* 2. saveFunc: () => doSomething().catch(reportError)
|
||||
* If doSomething fails, the error is reported but the dialog closes anyway.
|
||||
* 3. saveFunc: () => doSomething().catch((e) => { alert("BOOM"); throw new StayOpen(); })
|
||||
* If doSomething fails, an alert is shown, and the dialog stays open.
|
||||
*/
|
||||
export function saveModal(createFunc: (ctl: IModalControl, owner: MultiHolder) => ISaveModalOptions) {
|
||||
return modal((ctl, owner) => {
|
||||
const options = createFunc(ctl, owner);
|
||||
|
||||
const isSaving = Observable.create(owner, false);
|
||||
const isSaveDisabled = Computed.create(owner, (use) =>
|
||||
use(isSaving) || (options.saveDisabled ? use(options.saveDisabled) : false));
|
||||
|
||||
// We mark isSaving() observable to disable the save button while saveFunc() is pending.
|
||||
// (I decided against a waitWithObsSet() helper for this, since it's too specific to this case
|
||||
// when saveFunc() is prevented from being called multiple times in parallel.)
|
||||
async function save() {
|
||||
isSaving.set(true);
|
||||
try {
|
||||
await options.saveFunc();
|
||||
ctl.close(); // Close on success.
|
||||
} catch (err) {
|
||||
// Report errors. If saveFunc() reports its own error and wants the dialog to stay open,
|
||||
// it should throw StayOpen().
|
||||
if (!(err instanceof StayOpen)) {
|
||||
reportError(err);
|
||||
}
|
||||
isSaving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
cssModalTitle(options.title, testId('modal-title')),
|
||||
cssModalBody(options.body),
|
||||
cssModalButtons(
|
||||
bigPrimaryButton(options.saveLabel || 'Save',
|
||||
dom.boolAttr('disabled', isSaveDisabled),
|
||||
dom.on('click', save),
|
||||
testId('modal-confirm'),
|
||||
),
|
||||
options.hideCancel ? null : bigBasicButton('Cancel',
|
||||
dom.on('click', () => ctl.close()),
|
||||
testId('modal-cancel'),
|
||||
),
|
||||
),
|
||||
dom.onKeyDown({ Enter: () => isSaveDisabled.get() || save() }),
|
||||
options.width && cssModalWidth(options.width),
|
||||
options.modalArgs,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a simple confirm modal with 'Enter' bound to the confirm action.
|
||||
*
|
||||
* See saveModal() for error handling notes that here apply to the onConfirm callback.
|
||||
*/
|
||||
export function confirmModal(
|
||||
title: string,
|
||||
btnText: string,
|
||||
onConfirm: () => Promise<void>,
|
||||
explanation?: Element|string,
|
||||
{hideCancel}: {hideCancel?: boolean} = {},
|
||||
): void {
|
||||
return saveModal((ctl, owner): ISaveModalOptions => ({
|
||||
title,
|
||||
body: explanation || null,
|
||||
saveLabel: btnText,
|
||||
saveFunc: onConfirm,
|
||||
hideCancel,
|
||||
width: 'normal',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a simple spinner modal. The modal gets removed when `promise` resolves.
|
||||
*/
|
||||
export async function spinnerModal<T>(
|
||||
title: string,
|
||||
promise: Promise<T>): Promise<T> {
|
||||
|
||||
modal((ctl, owner) => {
|
||||
|
||||
// `finally` is missing from es2016, below is a work-around.
|
||||
const close = () => ctl.close();
|
||||
promise.then(close, close);
|
||||
|
||||
return [
|
||||
cssModalSpinner.cls(''),
|
||||
cssModalTitle(title),
|
||||
cssSpinner(loadingSpinner()),
|
||||
testId('modal-spinner'),
|
||||
];
|
||||
}, {
|
||||
noClickAway: true,
|
||||
noEscapeKey: true,
|
||||
});
|
||||
return await promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply this to a modal as
|
||||
* modal(() => [cssModalBody(...), cssModalWidth('normal')])
|
||||
* or
|
||||
* saveModal(() => {..., width: 'normal'})
|
||||
*/
|
||||
export function cssModalWidth(style: ModalWidth) {
|
||||
return cssModalDialog.cls('-' + style);
|
||||
}
|
||||
|
||||
/* CSS styled components */
|
||||
|
||||
const cssModalDialog = styled('div', `
|
||||
background-color: white;
|
||||
min-width: 428px;
|
||||
color: black;
|
||||
margin: auto;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 2px 18px 0 rgba(31,37,50,0.31), 0 0 1px 0 rgba(76,86,103,0.24);
|
||||
padding: 40px 64px;
|
||||
outline: none;
|
||||
|
||||
&-normal {
|
||||
max-width: 480px;
|
||||
}
|
||||
&-fixed-wide {
|
||||
width: 600px;
|
||||
}
|
||||
`);
|
||||
|
||||
export const cssModalTitle = styled('div', `
|
||||
font-size: ${vars.xxxlargeFontSize};
|
||||
font-weight: ${vars.headerControlTextWeight};
|
||||
color: ${colors.dark};
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 32px;
|
||||
`);
|
||||
|
||||
export const cssModalBody = styled('div', `
|
||||
margin: 16px 0;
|
||||
`);
|
||||
|
||||
export const cssModalButtons = styled('div', `
|
||||
margin: 40px 0 0 0;
|
||||
|
||||
& > button,
|
||||
& > .${cssButton.className} {
|
||||
margin: 0 8px 0 0;
|
||||
}
|
||||
`);
|
||||
|
||||
// For centering, we use 'margin: auto' on the flex item instead of 'justify-content: center' on
|
||||
// the flex container, to ensure the full item can be scrolled in case of overflow.
|
||||
// See https://stackoverflow.com/a/33455342/328565
|
||||
const cssModalBacker = styled('div', `
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
background-color: ${colors.backdrop};
|
||||
overflow-y: auto;
|
||||
`);
|
||||
|
||||
const cssSpinner = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 80px;
|
||||
margin: auto;
|
||||
`);
|
||||
|
||||
const cssModalSpinner = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`);
|
||||
198
app/client/ui2018/pages.ts
Normal file
198
app/client/ui2018/pages.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { isDesktop } from 'app/client/lib/browserInfo';
|
||||
import { cssEditorInput } from "app/client/ui/HomeLeftPane";
|
||||
import { itemHeader, itemHeaderWrapper, treeViewContainer } from "app/client/ui/TreeViewComponentCss";
|
||||
import { colors } from "app/client/ui2018/cssVars";
|
||||
import { icon } from "app/client/ui2018/icons";
|
||||
import { menu, menuItem, menuText } from "app/client/ui2018/menus";
|
||||
import { dom, domComputed, DomElementArg, makeTestId, observable, Observable, styled } from "grainjs";
|
||||
import * as ko from "knockout";
|
||||
|
||||
const testId = makeTestId('test-docpage-');
|
||||
|
||||
// the actions a page can do
|
||||
export interface PageActions {
|
||||
onRename: (name: string) => Promise<void>|any;
|
||||
onRemove: () => void;
|
||||
onDuplicate: () => void;
|
||||
isRemoveDisabled: () => boolean;
|
||||
isReadonly: Observable<boolean>;
|
||||
}
|
||||
|
||||
// to work with existing code we need to support both knockout and grainjs observable
|
||||
type NameType = Observable<string>|ko.Observable<string>;
|
||||
|
||||
// build the dom for a document page entry. It shows an icon (for now the first letter of the name,
|
||||
// but later we'll support user selected icon), the name and a dots menu containing a "Rename" and
|
||||
// "Remove" entries. Clicking "Rename" turns the page name into an editable input, which then call
|
||||
// the actions.onRename callback with the new name. Setting actions.onRemove to undefined disables
|
||||
// the item in the menu.
|
||||
export function buildPageDom(name: NameType, actions: PageActions, ...args: DomElementArg[]) {
|
||||
|
||||
// If name is blank, this page is censored, so don't include any options for manipulation.
|
||||
// We can get fancier about this later.
|
||||
const initName = ('peek' in name) ? name.peek() : name.get();
|
||||
if (initName === '') { return dom('div', '-'); }
|
||||
|
||||
const isRenaming = observable(false);
|
||||
const pageMenu = () => [
|
||||
menuItem(() => isRenaming.set(true), "Rename", testId('rename'),
|
||||
dom.cls('disabled', actions.isReadonly)),
|
||||
menuItem(actions.onRemove, 'Remove', testId('remove'),
|
||||
dom.cls('disabled', (use) => use(actions.isReadonly) || actions.isRemoveDisabled())),
|
||||
menuItem(actions.onDuplicate, 'Duplicate', testId('duplicate'),
|
||||
dom.cls('disabled', actions.isReadonly)),
|
||||
dom.maybe(actions.isReadonly, () => menuText('You do not have edit access to this document')),
|
||||
];
|
||||
let pageElem: HTMLElement;
|
||||
|
||||
// toggle '-renaming' class on the item's header. This is useful to make the background remain the
|
||||
// same while opening dots menu
|
||||
const lis = isRenaming.addListener(() => {
|
||||
const parent = pageElem.closest('.' + itemHeader.className);
|
||||
if (parent) {
|
||||
dom.clsElem(parent, itemHeader.className + '-renaming', isRenaming.get());
|
||||
}
|
||||
});
|
||||
|
||||
return pageElem = dom(
|
||||
'div',
|
||||
dom.autoDispose(lis),
|
||||
domComputed(isRenaming, (isrenaming) => (
|
||||
isrenaming ?
|
||||
cssPageItem(
|
||||
cssPageInitial(dom.text((use) => use(name)[0])),
|
||||
cssEditorInput(
|
||||
{
|
||||
initialValue: typeof name === 'function' ? name() : name.get() || '',
|
||||
save: (val) => actions.onRename(val),
|
||||
close: () => isRenaming.set(false)
|
||||
},
|
||||
testId('editor'),
|
||||
dom.on('mousedown', (ev) => ev.stopPropagation()),
|
||||
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); })
|
||||
),
|
||||
// Note that we don't pass extra args when renaming is on, because they usually includes
|
||||
// mouse event handlers interferring with input editor and yields wrong behavior on
|
||||
// firefox.
|
||||
) :
|
||||
cssPageItem(
|
||||
cssPageInitial(dom.text((use) => use(name)[0])),
|
||||
cssPageName(dom.text(name), testId('label')),
|
||||
cssPageMenuTrigger(
|
||||
cssPageIcon('Dots'),
|
||||
menu(pageMenu, {placement: 'bottom-start', parentSelectorToMark: '.' + itemHeader.className}),
|
||||
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
|
||||
|
||||
// Let's prevent dragging to start when un-intentionally holding the mouse down on '...' menu.
|
||||
dom.on('mousedown', (ev) => ev.stopPropagation()),
|
||||
testId('dots'),
|
||||
),
|
||||
// Prevents the default dragging behaviour that Firefox support for links which conflicts
|
||||
// with our own dragging pages.
|
||||
dom.on('dragstart', (ev) => ev.preventDefault()),
|
||||
args
|
||||
)
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
const cssPageItem = styled('a', `
|
||||
--icon-color: ${colors.slate};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
.${treeViewContainer.className}-close & {
|
||||
margin-left: 16px;
|
||||
}
|
||||
&, &:hover, &:focus {
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
color: inherit;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssPageInitial = styled('div', `
|
||||
flex-shrink: 0;
|
||||
color: white;
|
||||
border-radius: 3px;
|
||||
background-color: ${colors.slate};
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
text-align: center;
|
||||
margin-right: 8px;
|
||||
`);
|
||||
|
||||
const cssPageName = styled('div', `
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.${treeViewContainer.className}-close & {
|
||||
display: none;
|
||||
}
|
||||
`);
|
||||
|
||||
function onHoverSupport(yesNo: boolean) {
|
||||
// On desktop, we show page menu button on hover over page link. This isn't usable on mobile,
|
||||
// and interferes with clicks on iOS; so instead we show the button when the page is selected.
|
||||
//
|
||||
// We can achieve the distinction in CSS with
|
||||
// @media (hover: hover) { ... }
|
||||
// @media (hover: none) { ... }
|
||||
//
|
||||
// Except that it interferes with tests, because headless Chrome test on Linux incorrectly
|
||||
// matches (hover: none). To work around it, we assume desktop browsers can always hover,
|
||||
// and use trivial match-all/match-none media queries on desktop browsers.
|
||||
if (isDesktop()) {
|
||||
return yesNo ? 'all' : 'not all';
|
||||
} else {
|
||||
return yesNo ? '(hover: hover)' : '(hover: none)';
|
||||
}
|
||||
}
|
||||
|
||||
const cssPageMenuTrigger = styled('div', `
|
||||
cursor: default;
|
||||
display: none;
|
||||
margin-right: 4px;
|
||||
margin-left: auto;
|
||||
line-height: 0px;
|
||||
border-radius: 3px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
padding: 4px;
|
||||
|
||||
.${treeViewContainer.className}-close & {
|
||||
display: none !important;
|
||||
}
|
||||
&.weasel-popup-open {
|
||||
display: block;
|
||||
}
|
||||
@media ${onHoverSupport(true)} {
|
||||
.${itemHeaderWrapper.className}-not-dragging:hover & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@media ${onHoverSupport(false)} {
|
||||
.${itemHeaderWrapper.className}-not-dragging > .${itemHeader.className}.selected & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.${itemHeaderWrapper.className}-not-dragging &:hover, &.weasel-popup-open {
|
||||
background-color: ${colors.darkGrey};
|
||||
}
|
||||
.${itemHeaderWrapper.className}-not-dragging > .${itemHeader.className}.selected &:hover,
|
||||
.${itemHeaderWrapper.className}-not-dragging > .${itemHeader.className}.selected &.weasel-popup-open {
|
||||
background-color: ${colors.slate};
|
||||
}
|
||||
|
||||
.${itemHeader.className}.weasel-popup-open, .${itemHeader.className}-renaming {
|
||||
background-color: ${colors.mediumGrey};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssPageIcon = styled(icon, `
|
||||
.${itemHeader.className}.selected & {
|
||||
background-color: white;
|
||||
}
|
||||
`);
|
||||
145
app/client/ui2018/search.ts
Normal file
145
app/client/ui2018/search.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Search icon that expands to a search bar and collapse on 'x' or blur.
|
||||
* Takes a `SearchModel` that controls the search behavior.
|
||||
*/
|
||||
import { createGroup } from 'app/client/components/commands';
|
||||
import { reportError } from 'app/client/models/AppModel';
|
||||
import { SearchModel } from 'app/client/models/SearchModel';
|
||||
import { cssHoverCircle, cssTopBarBtn } from 'app/client/ui/TopBarCss';
|
||||
import { colors } from 'app/client/ui2018/cssVars';
|
||||
import { icon } from 'app/client/ui2018/icons';
|
||||
import { dom, input, styled } from 'grainjs';
|
||||
import { noTestId, TestId } from 'grainjs';
|
||||
import debounce = require('lodash/debounce');
|
||||
|
||||
export * from 'app/client/models/SearchModel';
|
||||
|
||||
const EXPAND_TIME = .5;
|
||||
|
||||
const searchWrapper = styled('div', `
|
||||
display: flex;
|
||||
flex: 0 0 initial;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid transparent;
|
||||
padding: 16px;
|
||||
width: 50px;
|
||||
height: 100%;
|
||||
max-height: 50px;
|
||||
transition: width 0.4s;
|
||||
&-expand {
|
||||
width: 100%;
|
||||
border: 1px solid grey;
|
||||
}
|
||||
`);
|
||||
|
||||
const expandedSearch = styled('div', `
|
||||
display: flex;
|
||||
flex-grow: 0;
|
||||
align-items: center;
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
transition: width ${EXPAND_TIME}s, opacity ${EXPAND_TIME / 2}s ${EXPAND_TIME / 2}s;
|
||||
.${searchWrapper.className}-expand > & {
|
||||
width: auto;
|
||||
flex-grow: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
`);
|
||||
|
||||
const searchInput = styled(input, `
|
||||
outline: none;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-left: 4px;
|
||||
box-sizing: border-box;
|
||||
width: 0;
|
||||
transition: width ${EXPAND_TIME}s;
|
||||
.${searchWrapper.className}-expand & {
|
||||
width: 100%;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssArrowBtn = styled('div', `
|
||||
font-size: 14px;
|
||||
padding: 3px;
|
||||
cursor: pointer;
|
||||
margin: 2px;
|
||||
visibility: hidden;
|
||||
|
||||
&.disabled {
|
||||
color: ${colors.darkGrey};
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.${searchWrapper.className}-expand & {
|
||||
visibility: visible;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssCloseBtn = styled(icon, `
|
||||
cursor: pointer;
|
||||
`);
|
||||
|
||||
export function searchBar(model: SearchModel, testId: TestId = noTestId) {
|
||||
const commandGroup = createGroup({
|
||||
find: () => { inputElem.focus(); inputElem.select(); },
|
||||
// On Mac, Firefox has a default behaviour witch causes to close the search bar on Cmd+g and
|
||||
// Cmd+shirt+G. Returning false is a Mousetrap convenience which prevents that.
|
||||
findNext: () => {model.findNext().catch(reportError); return false; },
|
||||
findPrev: () => {model.findPrev().catch(reportError); return false; },
|
||||
}, null, true);
|
||||
|
||||
const toggleMenu = debounce((_value?: boolean) => {
|
||||
model.isOpen.set(_value === undefined ? !model.isOpen.get() : _value);
|
||||
}, 100);
|
||||
const inputElem = searchInput(model.value, {onInput: true},
|
||||
{type: 'text', placeholder: 'Search in document'},
|
||||
dom.on('blur', () => toggleMenu(false)),
|
||||
dom.onKeyDown({
|
||||
Enter: () => model.findNext(),
|
||||
Escape: () => toggleMenu(false),
|
||||
// Catch both Tab and Shift+Tab to prevent focus entering unrelated editable label.
|
||||
Tab: () => toggleMenu(false),
|
||||
}),
|
||||
dom.on('focus', () => toggleMenu(true)),
|
||||
commandGroup.attach(),
|
||||
);
|
||||
|
||||
// Releases focus when closing the search bar, otherwise users could keep typing in without
|
||||
// noticing.
|
||||
const lis = model.isOpen.addListener(val => val || inputElem.blur());
|
||||
|
||||
return searchWrapper(
|
||||
testId('wrapper'),
|
||||
searchWrapper.cls('-expand', model.isOpen),
|
||||
dom.autoDispose(commandGroup),
|
||||
dom.autoDispose(lis),
|
||||
cssHoverCircle(
|
||||
cssTopBarBtn('Search',
|
||||
testId('icon'),
|
||||
dom.on('click', () => inputElem.focus())
|
||||
)
|
||||
),
|
||||
expandedSearch(
|
||||
testId('input'),
|
||||
inputElem,
|
||||
cssArrowBtn('\u2329',
|
||||
testId('prev'),
|
||||
// Prevent focus from being stolen from the input
|
||||
dom.on('mousedown', (event) => event.preventDefault()),
|
||||
dom.on('click', () => model.findPrev()),
|
||||
dom.cls('disabled', model.noMatch)),
|
||||
cssArrowBtn('\u232A',
|
||||
testId('next'),
|
||||
// Prevent focus from being stolen from the input
|
||||
dom.on('mousedown', (event) => event.preventDefault()),
|
||||
dom.on('click', () => model.findNext()),
|
||||
dom.cls('disabled', model.noMatch)),
|
||||
cssCloseBtn('CrossSmall',
|
||||
testId('close'),
|
||||
dom.on('click', () => toggleMenu(false)))
|
||||
)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user