gristlabs_grist-core/app/client/ui2018/editableLabel.ts

159 lines
4.8 KiB
TypeScript
Raw Normal View History

/**
* 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, `
2022-02-19 09:46:49 +00:00
/* 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
);
}