gristlabs_grist-core/app/client/ui2018/editableLabel.ts
George Gevoian 3e49fe9a50 (core) Polish ChoiceListEntry drag and drop
Summary:
A green line indicating the insertion point is now shown in the
ChoiceListEntry component when dragging and dropping choices, similar
to the one shown in the choice list cell editor.

Test Plan: Tested manually.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3529
2022-07-19 08:14:04 -07:00

159 lines
4.8 KiB
TypeScript

/**
* 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, DomArg, 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 appearance */
-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>;
export interface EditableLabelOptions {
save: SaveFunc;
args?: Array<DomArg<HTMLDivElement>>;
inputArgs?: Array<DomArg<HTMLInputElement>>;
}
/**
* 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>, options: EditableLabelOptions) {
const {save, args, inputArgs} = options;
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()),
...inputArgs ?? [],
),
...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: Array<DomArg<HTMLInputElement>>) {
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: Array<DomArg<HTMLInputElement>>) {
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
);
}