2020-10-02 15:10:00 +00:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
2022-09-06 01:51:57 +00:00
|
|
|
import { theme } from 'app/client/ui2018/cssVars';
|
2021-03-04 11:04:02 +00:00
|
|
|
import { dom, DomArg, styled } from 'grainjs';
|
2020-10-02 15:10:00 +00:00
|
|
|
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 */
|
2020-10-02 15:10:00 +00:00
|
|
|
-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;
|
2022-09-06 01:51:57 +00:00
|
|
|
border: 1px solid ${theme.inputBorder};
|
2020-10-02 15:10:00 +00:00
|
|
|
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 }
|
|
|
|
|
2023-03-23 18:22:28 +00:00
|
|
|
type SaveFunc = (value: string) => void|PromiseLike<void>;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2022-07-18 17:05:35 +00:00
|
|
|
export interface EditableLabelOptions {
|
|
|
|
save: SaveFunc;
|
|
|
|
args?: Array<DomArg<HTMLDivElement>>;
|
|
|
|
inputArgs?: Array<DomArg<HTMLInputElement>>;
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
/**
|
|
|
|
* 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 .
|
|
|
|
*/
|
2022-07-18 17:05:35 +00:00
|
|
|
export function editableLabel(label: Observable<string>, options: EditableLabelOptions) {
|
|
|
|
const {save, args, inputArgs} = options;
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
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()),
|
2022-07-18 17:05:35 +00:00
|
|
|
...inputArgs ?? [],
|
2020-10-02 15:10:00 +00:00
|
|
|
),
|
2022-07-18 17:05:35 +00:00
|
|
|
...args ?? [],
|
2020-10-02 15:10:00 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Provides a text input element that pretty much behaves like the editableLabel only it shows as a
|
2022-09-06 01:51:57 +00:00
|
|
|
* regular input within a rigid static frame. It takes in an observable that is set on Enter or loss
|
2020-10-02 15:10:00 +00:00
|
|
|
* 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.
|
|
|
|
*/
|
2021-03-04 11:04:02 +00:00
|
|
|
export function textInput(label: Observable<string>, save: SaveFunc, ...args: Array<DomArg<HTMLInputElement>>) {
|
2020-10-02 15:10:00 +00:00
|
|
|
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,
|
2021-03-04 11:04:02 +00:00
|
|
|
...args: Array<DomArg<HTMLInputElement>>) {
|
2020-10-02 15:10:00 +00:00
|
|
|
let status: Status = Status.NORMAL;
|
|
|
|
let inputEl: HTMLInputElement;
|
|
|
|
|
|
|
|
// When label changes updates the input, unless in the middle of editing.
|
2021-05-23 17:43:11 +00:00
|
|
|
const lis = value.addListener((val) => { if (status !== Status.EDITING) { setValue(val); } });
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
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
|
|
|
|
);
|
|
|
|
}
|