mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
150 lines
4.5 KiB
TypeScript
150 lines
4.5 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, 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
|
||
|
);
|
||
|
}
|