gristlabs_grist-core/app/client/lib/koForm.js
Paul Fitzpatrick 1654a2681f (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
2020-10-02 13:24:21 -04:00

1164 lines
44 KiB
JavaScript

/**
* koForm provides a number of styled elements (buttons, checkbox, etc) that are tied to
* observables to simplify and standardize the way we construct UI elements (e.g. forms).
*
* TODO: There is some divergence in class names that we use throughout Grist. For example,
* active vs mod-active and disabled vs mod-disabled. We should standardize.
*/
// Use the browser globals in a way that allows replacing them with mocks in tests.
var G = require('./browserGlobals').get('$', 'window', 'document');
const identity = require('lodash/identity');
const defaults = require('lodash/defaults');
const debounce = require('lodash/debounce');
const pick = require('lodash/pick');
var ko = require('knockout');
var Promise = require('bluebird');
var gutil = require('app/common/gutil');
var commands = require('../components/commands');
var dom = require('./dom');
var kd = require('./koDom');
var koArray = require('./koArray');
var modelUtil = require('../models/modelUtil');
var setSaveValue = modelUtil.setSaveValue;
/**
* Creates a button-looking div inside a buttonGroup; when clicked, clickFunc() will be called.
* The button is not clickable if it contains the class 'disabled'.
*/
exports.button = function(clickFunc, ...moreContentArgs) {
return dom('div.kf_button.flexitem',
dom.on('click', function() {
if (!this.classList.contains('disabled')) {
clickFunc();
}
}),
moreContentArgs
);
};
/**
* Creates a button with an accented appearance.
* The button is not clickable if it contains the class 'disabled'.
*/
exports.accentButton = function(clickFunc, ...moreContentArgs) {
return this.button(clickFunc,
{'class': 'kf_button flexitem accent'},
moreContentArgs
);
};
/**
* Creates a button with a minimal appearance for use in prompts.
* The button is not clickable if it contains the class 'disabled'.
*/
exports.liteButton = function(clickFunc, ...moreContentArgs) {
return this.button(clickFunc,
{'class': 'kf_button flexitem lite'},
moreContentArgs
);
};
/**
* Creates a bigger button with a logo, used for "sign in with google/github/etc" buttons.
* The button is not clickable if it contains the class 'disabled'.
*/
exports.logoButton = function(clickFunc, logoUrl, text, ...moreContentArgs) {
return this.button(clickFunc,
{'class': 'kf_button kf_logo_button flexitem flexhbox'},
dom('div.kf_btn_logo', { style: `background-image: url(${logoUrl})` }),
dom('div.kf_btn_text', text),
moreContentArgs
);
};
/**
* Creates a button group. Arguments should be `button` and `checkButton` objects.
*/
exports.buttonGroup = function(moreButtonArgs) {
return dom('div.kf_button_group.kf_elem.flexhbox',
dom.fwdArgs(arguments, 0));
};
/**
* Creates a button group with an accented appearance.
* Arguments should be `button` and `checkButton` objects.
*/
exports.accentButtonGroup = function(moreButtonArgs) {
return this.buttonGroup(
[{'class': 'kf_button_group kf_elem flexhbox accent'}].concat(dom.fwdArgs(arguments, 0))
);
};
/**
* Creates a button group with a minimal appearance.
* Arguments should be `button` and `checkButton` objects.
*/
exports.liteButtonGroup = function(moreButtonArgs) {
return this.buttonGroup(
[{'class': 'kf_button_group kf_elem flexhbox lite'}].concat(dom.fwdArgs(arguments, 0))
);
};
/**
* Creates a button-looking div that acts as a checkbox, toggling `valueObservable` on click.
*/
exports.checkButton = function(valueObservable, moreContentArgs) {
return dom('div.kf_button.kf_check_button.flexitem',
kd.toggleClass('active', valueObservable),
dom.on('click', function() {
if (!this.classList.contains('disabled')) {
setSaveValue(valueObservable, !valueObservable());
}
}),
dom.fwdArgs(arguments, 1));
};
/**
* Creates a button-looking div that acts as a checkbox, toggling `valueObservable` on click.
* Very similar to `checkButton` but looks flat and does not need to be in a group.
*
* TODO: checkButton and flatCheckButton are identical in function but differ in style and
* class name conventions. We should reconcile them.
*/
exports.flatCheckButton = function(valueObservable, moreContentArgs) {
return dom('div.flexnone',
kd.toggleClass('mod-active', valueObservable),
dom.on('click', function() {
if (!this.classList.contains('mod-disabled')) {
setSaveValue(valueObservable, !valueObservable());
}
}),
dom.fwdArgs(arguments, 1));
};
/**
* Creates a group of buttons of which only one may be chosen. Arguments should be `optionButton`
* objects. The single `valueObservable` reflects the value of the selected `optionButton`.
*/
exports.buttonSelect = function(valueObservable, moreButtonArgs) {
var groupElem = dom('div.kf_button_group.kf_elem.flexhbox', dom.fwdArgs(arguments, 1));
// TODO: Is adding ":not(.disabled)" the best way to avoid execution?
G.$(groupElem).on('click', '.kf_button:not(.disabled)', function() {
setSaveValue(valueObservable, ko.utils.domData.get(this, 'kfOptionValue'));
});
kd.makeBinding(valueObservable, function(groupElem, value) {
Array.prototype.forEach.call(groupElem.querySelectorAll('.kf_button'), function(elem, i) {
var v = ko.utils.domData.get(elem, 'kfOptionValue');
elem.classList.toggle('active', v === value);
});
})(groupElem);
return groupElem;
};
/**
* Creates a button-like div to use inside a `buttonSelect` group. The `value` will become the
* value of the `buttonSelect` observable when this button is selected.
*/
exports.optionButton = function(value, moreContentArgs) {
return dom('div.kf_button.flexitem',
function(elem) { ko.utils.domData.set(elem, 'kfOptionValue', value); },
dom.fwdArgs(arguments, 1));
};
/**
* Creates a speech-bubble-like div intended to give more information and options affecting
* its parent when hovered.
*/
exports.toolTip = function(contentArgs) {
return dom('div.kf_tooltip',
dom('div.kf_tooltip_pointer'),
dom('div.kf_tooltip_content', dom.fwdArgs(arguments, 0)),
dom.defer(function(elem) {
var elemWidth = elem.getBoundingClientRect().width;
var parentRect = elem.parentNode.getBoundingClientRect();
elem.style.left = (-elemWidth/2 + parentRect.width/2) + 'px';
elem.style.top = parentRect.height + 'px';
})
);
};
/**
* Creates a prompt to provide feedback or request more information in the sidepane.
*/
exports.prompt = function(contentArgs) {
return dom('div.kf_prompt',
dom('div.kf_prompt_pointer'),
dom('div.kf_prompt_pointer_overlap'),
dom('div.kf_prompt_content', dom.fwdArgs(arguments, 0))
);
};
/**
* Checkbox which toggles `valueObservable`. Other arguments become part of the clickable label.
*/
exports.checkbox = function(valueObservable, moreContentArgs) {
return dom('label.kf_checkbox_label.kf_elem',
dom('input.kf_checkbox', {type: 'checkbox'},
kd.makeBinding(valueObservable, function(elem, value) {
elem.checked = value;
}),
dom.on('change', function() {
setSaveValue(valueObservable, this.checked);
})
),
dom.fwdArgs(arguments, 1));
};
/**
* Radio button for a particular value of the given observable. It is checked when the observable
* matches the value, and selecting it sets the observable to the value. Other arguments become
* part of the clickable label.
*/
exports.radio = function(value, valueObservable, ...domArgs) {
return dom('label.kf_radio_label',
dom('input.kf_radio', {type: 'radio'},
kd.makeBinding(valueObservable, (elem, val) => { elem.checked = (val === value); }),
dom.on('change', function() {
if (this.checked) {
setSaveValue(valueObservable, value);
}
})
),
...domArgs
);
};
/**
* Create and return DOM for a spinner widget.
* valueObservable: observable for the value, may have save interface.
* This value is not displayed by the created widget.
* getNewValue(value, dir): called with the current value and 1 or -1 direction,
* should return the new value for valueObservable.
* shouldDisable(value, dir): called with current value and 1 or -1 direction,
* should return whether the button in that direction should be enabled.
*/
function genSpinner(valueObservable, getNewValue, shouldDisable) {
let timeout = null;
let origValue = null;
function startChange(elem, direction) {
stopAutoRepeat();
G.$(G.window).on('mouseup', onMouseUp);
origValue = valueObservable.peek();
doChange(direction, true);
}
function onMouseUp() {
G.$(G.window).off('mouseup', onMouseUp);
stopAutoRepeat();
setSaveValue(valueObservable, valueObservable.peek(), origValue);
}
function doChange(direction, isFirst) {
const newValue = getNewValue(valueObservable.peek(), direction);
if (newValue !== valueObservable.peek()) {
valueObservable(newValue);
timeout = setTimeout(doChange, isFirst ? 600 : 100, direction, false);
}
}
function stopAutoRepeat() {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
}
return dom('div.kf_spinner',
dom('div.kf_spinner_half', dom('div.kf_spinner_arrow.up'),
kd.toggleClass('disabled', () => shouldDisable(valueObservable(), 1)),
dom.on('mousedown', () => { startChange(this, 1); })
),
dom('div.kf_spinner_half', dom('div.kf_spinner_arrow.down'),
kd.toggleClass('disabled', () => shouldDisable(valueObservable(), -1)),
dom.on('mousedown', () => { startChange(this, -1); })
),
dom.on('dblclick', () => false)
);
}
/**
* Creates a spinner item linked to `valueObservable`.
* @param {Number} optMin - Optional spinner lower bound
* @param {Number} optMax - Optional spinner upper bound
*/
exports.spinner = function(valueObservable, stepSizeObservable, optMin, optMax) {
var max = optMax !== undefined ? optMax : Infinity;
var min = optMin !== undefined ? optMin : -Infinity;
function getNewValue(value, direction) {
const step = (ko.unwrap(stepSizeObservable) || 1) * direction;
// Adding step quickly accumulates floating-point errors. We want to keep the value a multiple
// of step, as well as only keep significant decimal digits. The latter is done by converting
// to string and back using 15 digits of precision (max guaranteed to be significant).
value = value || 0;
value = Math.round(value / step) * step + step;
value = parseFloat(value.toPrecision(15));
return gutil.clamp(value, min, max);
}
function shouldDisable(value, direction) {
return (direction > 0) ? (value >= max) : (value <= min);
}
return genSpinner(valueObservable, getNewValue, shouldDisable);
};
/**
* Creates a select spinner item to loop through the `optionObservable` array,
* setting visible value to `valueObservable`.
*/
exports.selectSpinner = function(valueObservable, optionObservable) {
function getNewValue(value, direction) {
const choices = optionObservable.peek();
const index = choices.indexOf(value);
const newIndex = gutil.mod(index + direction, choices.length);
return choices[newIndex];
}
function shouldDisable(value, direction) {
return optionObservable().length <= 1;
}
return genSpinner(valueObservable, getNewValue, shouldDisable);
};
/**
* Creates an alignment selector linked to `valueObservable`.
*/
exports.alignmentSelector = function(valueObservable) {
return this.buttonSelect(valueObservable,
this.optionButton("left", dom('span.glyphicon.glyphicon-align-left'),
dom.testId('koForm_alignLeft')),
this.optionButton("center", dom('span.glyphicon.glyphicon-align-center'),
dom.testId('koForm_alignCenter')),
this.optionButton("right", dom('span.glyphicon.glyphicon-align-right'),
dom.testId('koForm_alignRight'))
);
};
/**
* Label with a collapser triangle in front, which may be clicked to toggle `isCollapsedObs`
* observable.
*/
exports.collapserLabel = function(isCollapsedObs, moreContentArgs) {
return dom('div.kf_collapser.kf_elem',
dom('span.kf_triangle_toggle',
kd.text(function() {
return isCollapsedObs() ? '\u25BA' : '\u25BC';
})
),
dom.on('click', function() {
isCollapsedObs(!isCollapsedObs.peek());
}),
dom.fwdArgs(arguments, 1));
};
/**
* Creates a collapsible section. The argument must be a function which takes a boolean observable
* (isCollapsed) as input, and should return an array of elements. The first element is always
* shown, while the rest will be toggled by `isCollapsed` observable. The isMountedCollapsed
* parameter controls the initial state of the collapsible. When true or omitted, the collapsible
* will be closed on load. Otherwise, the collapsible will initialize expanded/uncollapsed.
*
* kf.collapsible(function(isCollapsed) {
* return [
* kf.collapserLabel(isCollapsed, 'Indents'),
* kf.row(...),
* kf.row(...)
* ];
* });
* Returns an array of two items: the always-shown element, and a div containing the rest.
*/
exports.collapsible = function(contentFunc, isMountedCollapsed) {
var isCollapsed = ko.observable(isMountedCollapsed === undefined ? true : isMountedCollapsed);
var content = contentFunc(isCollapsed);
return [
content[0],
dom('div',
kd.hide(isCollapsed),
dom.fwdArgs(content, 1))
];
};
/**
* Creates a draggable list of rows. The contentArray argument must be an observable array.
* The callbackObj argument should include some or all of the following methods:
* reorder, remove, and receive.
* The reorder callback is executed if an item is dragged and dropped to a new position
* within the same collection or draggable container. The remove and receive callbacks
* are executed together only when an item from one collection is dropped on a different
* collection. The remove callback may be executed alone when users click on the "minus" icon
* for draggable items. The connectAllDraggables function must be called on draggables to
* enable the remove/receive operation between separate draggables.
*
* Each callback must update the respective model tied to the draggable component,
* or the equivalency between the UI and the observable array may be broken. When
* a method is implemented, but the callback cannot update the model for any reason
* (e.g., failure), then this failure should be communicated to the component either
* by throwing an Error in the callback, or by returning a rejected Promise.
*
*
* reorder(item, nextItem)
* @param {Object} item The item being relocated/moved
* @param {Object} nextItem The next item immediately following the new position,
* or null, when the item is moved to the end of the collection.
* remove(item)
* @param {Object} item The item that should be removed from the collection.
* @returns {Object} The item removed from the observable array. This
* value is passed to the receive function as the
* its item parameter. This value must include all the
* necessary data required for connected draggables
* to successfully insert the new value within their
* respective receive functions.
* receive(item, nextItem)
* @param {Object} item The item to insert in the collection.
* @param {Object} nextItem The next item from item's new position. This value
* will be null when item is moved to the end of the list.
*
* @param {Array} contentArray KoArray of model items
* @param {Function} itemCreateFunc Identical to koDom.foreach's itemCreateFunc, this
* function is called as `itemCreateFunc(item)` for each
* array element. Must return a single Node, or null or
* undefined to omit that node.
* @param {Object} options An object containing the reorder, remove, receive
* callback functions, and all other draggable configuration
* options --
* @param {Boolean} options.removeButton Controls whether the clickable remove/minus icon is
* displayed. If true, this button triggers the remove
* function on click.
* @param {String} options.axis Determines if the list is displayed vertically 'y' or
* horizontally 'x'.
* @param {Boolean|Function} drag_indicator Include the drag indicator. Defaults to true. Accepts
* also a function that returns a dom element. In which
* case, it will be used to create the drag indicator.
* @returns {Node} The DOM Node for the draggable container
*/
exports.draggableList = function(contentArray, itemCreateFunc, options) {
options = options || {};
defaults(options, {
removeButton: true,
axis: "y",
drag_indicator: true,
itemClass: 'kf_draggable__item'
});
var reorderFunc, removeFunc;
itemCreateFunc = itemCreateFunc || identity;
var list = dom('div.kf_drag_container',
function(elem) {
if (options.reorder) {
reorderFunc = Promise.method(options.reorder);
ko.utils.domData.set(elem, 'reorderFunc', reorderFunc);
}
if (options.remove) {
removeFunc = Promise.method(options.remove);
ko.utils.domData.set(elem, 'removeFunc', removeFunc);
}
if (options.receive) {
ko.utils.domData.set(elem, 'receiveFunc', Promise.method(options.receive));
}
},
kd.foreach(contentArray, item => {
var row = itemCreateFunc(item);
if (row) {
return dom('div.kf_draggable',
// Fix for JQueryUI bug where mousedown on draggable elements fail to blur
// active element. See: https://bugs.jqueryui.com/ticket/4261
dom.on('mousedown', () => G.document.activeElement.blur()),
kd.cssClass(options.itemClass),
(options.drag_indicator ?
(typeof options.drag_indicator === 'boolean' ?
dom('span.kf_drag_indicator.glyphicon.glyphicon-option-vertical') :
options.drag_indicator()
) : null),
kd.style('display', options.axis === 'x' ? 'inline-block' : 'block'),
kd.domData('model', item),
kd.maybe(removeFunc !== undefined && options.removeButton, function() {
return dom('span.drag_delete.glyphicon.glyphicon-remove',
dom.on('click', function() {
removeFunc(item)
.catch(function(err) {
console.warn('Failed to remove item', err);
});
})
);
}),
dom('span.kf_draggable_content.flexauto', row));
} else {
return null;
}
})
);
G.$(list).sortable({
axis: options.axis,
tolerance: "pointer",
forcePlaceholderSize: true,
placeholder: 'kf_draggable__placeholder--' + (options.axis === 'x' ? 'horizontal' : 'vertical')
});
if (reorderFunc === undefined) {
G.$(list).sortable("option", {disabled: true});
}
G.$(list).on('sortstart', function(e, ui) {
ko.utils.domData.set(ui.item[0], 'originalParent', ui.item.parent());
ko.utils.domData.set(ui.item[0], 'originalPrev', ui.item.prev());
});
G.$(list).on('sortstop', function(e, ui) {
if (!ko.utils.domData.get(ui.item[0], 'crossedContainers')) {
handleReorderStop.bind(null, list).call(this, e, ui);
} else {
handleConnectedStop.call(list, e, ui);
}
});
return list;
};
function handleReorderStop(container, e, ui) {
var reorderFunc = ko.utils.domData.get(container, 'reorderFunc');
var originalPrev = ko.utils.domData.get(ui.item[0], 'originalPrev');
if (reorderFunc && !ui.item.prev().is(originalPrev)) {
var movingItem = ko.utils.domData.get(ui.item[0], 'model');
reorderFunc(movingItem, getNextDraggableItemModel(ui.item))
.catch(function(err) {
console.warn('Failed to reorder item', err);
G.$(container).sortable('cancel');
});
}
resetDraggedItem(ui.item[0]);
}
function handleConnectedStop(e, ui) {
var originalParent = ko.utils.domData.get(ui.item[0], 'originalParent');
var removeOriginal = ko.utils.domData.get(originalParent[0], 'removeFunc');
var receive = ko.utils.domData.get(ui.item.parent()[0], 'receiveFunc');
if (removeOriginal && receive) {
removeOriginal(ko.utils.domData.get(ui.item[0], 'model'))
.then(function(removedItem) {
return receive(removedItem, getNextDraggableItemModel(ui.item))
.then(function() {
ui.item.remove();
})
.catch(revertRemovedItem.bind(null, ui, originalParent, removedItem));
})
.catch(function(err) {
console.warn('Error removing item', err);
G.$(originalParent).sortable('cancel');
})
.finally(function() {
resetDraggedItem(ui.item[0]);
});
} else {
console.warn('Missing remove or receive');
}
}
function revertRemovedItem(ui, parent, item, err) {
console.warn('Error receiving item. Trying to return removed item.', err);
var originalReceiveFunc = ko.utils.domData.get(parent[0], 'receiveFunc');
if (originalReceiveFunc) {
var originalPrev = ko.utils.domData.get(ui.item[0], 'originalPrev');
var originalNextItem = originalPrev.length > 0 ?
getNextDraggableItemModel(originalPrev) :
getDraggableItemModel(parent.children('.kf_draggable').first());
originalReceiveFunc(item, originalNextItem)
.catch(function(err) {
console.warn('Failed to receive item in original collection.', err);
}).finally(function() {
ui.item.remove();
});
}
}
function getDraggableItemModel(elem) {
if (elem.length && elem.length > 0) {
return ko.utils.domData.get(elem[0], 'model');
}
return null;
}
function getNextDraggableItemModel(elem) {
return elem.next ? getDraggableItemModel(elem.next('.kf_draggable')) : null;
}
function resetDraggedItem(elem) {
ko.utils.domData.set(elem, 'originalPrev', null);
ko.utils.domData.set(elem, 'originalParent', null);
ko.utils.domData.set(elem, 'crossedContainers', false);
}
function enableDraggableConnection(draggable) {
G.$(draggable).on('sortremove', function(e, ui) {
ko.utils.domData.set(ui.item[0], 'crossedContainers', true);
ko.utils.domData.set(ui.item[0], 'stopIndex', ui.item.index());
});
if (G.$(draggable).sortable("option", "disabled") && (
ko.utils.domData.get(draggable, 'receiveFunc') ||
ko.utils.domData.get(draggable, 'removeFunc')
)) {
G.$(draggable).sortable( "option", { disabled: false });
}
}
function connectDraggableToClass(draggable, className) {
enableDraggableConnection(draggable);
G.$(draggable).addClass(className);
G.$(draggable).sortable("option", {connectWith: "." + className});
}
/**
* Connects 2 or more draggableList components together. This connection allows any of the
* draggable components to drag & drop items into and out of any other connected draggable.
* @param {Object} draggableArgs 2 or more draggableList objects
*/
var connectedDraggables = 0;
exports.connectAllDraggables = function(draggableArgs) {
if (draggableArgs.length < 2) {
console.warn('connectAllDraggables requires at least 2 draggable components');
}
var className = "connected-draggable-" + connectedDraggables++;
for (var i=0; i<arguments.length; i++) {
connectDraggableToClass(arguments[i], className);
}
};
/**
* Connects 1 draggable to another, without the inverse. Elements may be dragged from
* the fromDraggable to the toDraggable, but not in the other direction.
* @param {Object} fromDraggable A source draggableList object
* @param {Object} toDraggable A destination draggableList object
*/
exports.connectDraggableOneWay = function(fromDraggable, toDraggable) {
fromDraggable.id = "connected-draggable-" + connectedDraggables++;
toDraggable.id = "connected-draggable-" + connectedDraggables++;
enableDraggableConnection(fromDraggable);
enableDraggableConnection(toDraggable);
G.$(fromDraggable).sortable("option", {connectWith: "#" + toDraggable.id});
};
/**
* A bold label. Typically takes a string argument, but accepts any children.
*/
exports.label = function(moreContentArgs) {
return dom('div.kf_label.kf_elem', dom.fwdArgs(arguments, 0));
};
/**
* A regular (not bold) label. Typically takes a string argument, but accepts any children.
*/
exports.lightLabel = function(moreContentArgs) {
return dom('div.kf_light_label.kf_elem', dom.fwdArgs(arguments, 0));
};
/**
* Creates a set of tabs, with a look suitable for the middle of a pane. It takes no arguments,
* and should be followed by `midTab()` calls under the same DOM element.
* @param {Observable} optObservable The observable for the index of the selected tab, will be
* created if omitted.
*/
exports.midTabs = function(optObservable) {
return _initTabs(optObservable, '.kf_mid_tab_label',
dom('div.flexitem.kf_mid_tabs',
dom('div.flexhbox.flexnone.kf_mid_tab_labels'),
exports.scrollable()
)
);
};
/**
* Adds a tab to the `midTabs` container created previously under the same DOM element. The
* `label` is a label or Node for the tab label; the rest is the content of the tab.
* The content is created once, but is hidden when a different tab is selected.
*/
exports.midTab = function(label, moreContentArgs) {
return _addTab('.kf_mid_tabs',
dom('div.kf_mid_tab_label.flexitem', label),
dom('div.kf_mid_tab_content', dom.fwdArgs(arguments, 1)));
};
/**
* A textbox for entering numbers, although the tied `valueObservable` is a string.
* We may want to replace it with a widget with up-down arrows to change the values, and support
* for units (such as px, in, %, etc).
* @param {Object} options.placeholder Placeholder text for the textbox
* @param {Object} options.min The minimum (numeric or date-time) value for the item, which must not be greater than its maximum (max attribute) value.
* @param {Object} options.max The maximum (numeric or date-time) value for this item, which must not be less than its minimum (min attribute) value.
*/
exports.numText = function(valueObservable, options) {
var attr = {type: 'number'};
options = options || {};
if (options.placeholder) attr.placeholder = options.placeholder;
if (typeof options.min !== 'undefined') attr.min = options.min;
if (typeof options.max !== 'undefined') attr.max = options.max;
return dom('div.kf_elem', dom('input.kf_num_text', attr,
kd.value(valueObservable),
// while 'change' seems better suited, sometimes it does not fire when user click on the spinner
// arrow before it moves the cursor away.
dom.on('input', function() {
setSaveValue(valueObservable, Number(this.value));
})
));
};
/**
* Helper function for textboxes tied to `valueObservable`.
* @param {Number} [options.maxSize] The max length of the text, which
* also affects the box size.
* @param {String} [options.placeholder] Placeholder text for the textbox
* @param {Function} [options.disabled] Plain boolean or observable
* boolean value or Function returning
* a Boolean that controls whether
* the "disabled" attribute is true or false
* for the input element.
* @param {Number} [option.delay] Wait interval in milliseconds until user stops
* typing before save its input. Using this options
* allows user to not change focus for saving input.
* @return {Object} Constructed DOM
*/
function textInput(valueObservable, options, moreArgs) {
var attr = {};
if (options) {
if (options.type) {
attr.type = options.type;
}
if (options.maxSize) {
attr.maxlength = options.maxSize;
attr.style = 'max-width: ' + (options.maxSize + 2) + 'em';
}
if (options.placeholder) {
attr.placeholder = options.placeholder;
}
}
var saveValue = e => setSaveValue(valueObservable, e.target.value);
var debounced = debounce(saveValue, options.delay);
var setValue = elem => {
if (options && options.delay) {
dom(elem,
dom.on('input', debounced),
dom.on('change', e => {
debounced(e);
debounced.flush();
}));
} else {
dom(elem, dom.on('change', saveValue));
}
};
return dom('div.kf_elem',
dom('input.kf_text',
attr,
kd.toggleDisabled(options.disabled || false),
kd.value(valueObservable),
setValue,
dom.fwdArgs(arguments, 2))
);
}
/**
* A regular textbox tied to `valueObservable`.
*/
exports.text = function(valueObservable, options, ...moreArgs) {
options = Object.assign({type: 'text'}, options || {});
return textInput(valueObservable, options, moreArgs);
};
/**
* A color picker tied to `valueObservable`.
*/
exports.color = function(valueObservable, ...moreArgs) {
// On some machine (seen on chrome running on a Mac) the `change` event fires as many times as the `input` event, hence debounce.
const saveValue = debounce(e => setSaveValue(valueObservable, e.target.value), 300);
return dom('div.kf_elem',
dom('input.kf_color',
{type: 'color'},
kd.value(valueObservable),
dom.on('change', saveValue),
...moreArgs
));
};
/**
* Identical to koForm.text, but with input type=password
*/
exports.password = function(valueObservable, options, ...moreArgs) {
options = Object.assign({type: 'password'}, options || {});
return textInput(valueObservable, options, moreArgs);
};
/**
* A status panel which reflects 1 of 4 possible states based on the value of valueObservable.
* Users provide a mapping between possible valueObservable values and the 4 possible states
* of this status panel: `success`, `info`, `warning`, and `error`. The status panel
* displays circle icon or indicator reflecting the state of valueObservable (success=green,
* info=blue, warning=orange, error=red).
*
* Users may provide either a strings or an objects with "value" and "label" properties
* for each of the state properties. Each of these strings (or object.value properties)
* must represent a possible value of valueObservable.
*
* For example:
* statusPanel(
* ko.observable('OK'),
* {
* success: 'OK',
* info: {value: 'Unknown', label: 'Status unknown'}
* }
* )
*
* @param {string} valueObservable A knockout observable containing a string
* @param {string|Object} options.success
* @param {string|Object} options.info
* @param {string|Object} options.warning
* @param {string|Object} options.error
* @param {string} options.heading When present, the heading string appears
* as a header within the panel above the
* status label (if any).
* @return {Object} DOM
*/
exports.statusPanel = function(valueObservable, options) {
var statusMap = {};
['success', 'info', 'warning', 'error'].forEach(function(key) {
var statusLookupValue;
if (options[key]) {
statusLookupValue = options[key].value !== undefined ? options[key].value : options[key];
statusMap[statusLookupValue] = {};
statusMap[statusLookupValue].className = 'kf_status_' + key;
statusMap[statusLookupValue].label = options[key].label || null;
}
});
var hasLabel = ko.pureComputed(function() {
return statusMap[valueObservable()].label !== undefined;
});
return dom('div.kf_status_panel.flexhbox',
dom.autoDispose(hasLabel),
dom('div.kf_status_indicator.flexauto',
kd.cssClass(function() {
if (statusMap[valueObservable()]) {
return statusMap[valueObservable()].className;
}
console.error('Status must match an available status code', Object.keys(statusMap));
}),
'\u25CF' // solid circle
),
dom('div.kf_status_detail.flexauto',
kd.maybe(options.heading, function() {
return exports.row(exports.label(options.heading));
}),
kd.maybe(hasLabel, function() {
return exports.row(exports.lightLabel(
kd.text(ko.pureComputed(function() {
return statusMap[valueObservable()].label;
}))
));
})
)
);
};
/**
* A label which can have editing turned on and off
* When clicked a input area is created on top of the text.
* The Enter key or a blur event calls save() on the observable.
* The input area is removed if the user presses Esc without triggering any change.
* @param {Observable} valueObservable - If the observable has a save interface it will be used to
* save changes. see modelUtil.addSaveInterface
* @param {Observable} optToggleObservable - If another observable is provided, it will be used to
* toggle whether or not the field is editable. It will also prevent clicks from affecting whether
* the label is editable.
* @param {Observable} optCommands - Optional commands to bind to the input.
*/
exports.editableLabel = function(valueObservable, optToggleObservable, optCommands) {
var isEditing = optToggleObservable || ko.observable(false);
var cancelEdit = false;
var editingCommands = Object.assign({
cancel: function() {
cancelEdit = true;
isEditing(false);
},
accept: function() {
cancelEdit = false;
isEditing(false);
}
}, optCommands || {});
var contentSizer;
return dom('div.kf_editable_label',
dom('div.kf_elabel_text',
kd.text(valueObservable),
kd.hide(isEditing)
),
contentSizer = dom('div.elabel_content_measure'),
(!optToggleObservable ? dom.on('click', () => isEditing(true)) : null),
kd.maybe(isEditing, function() {
var commandGroup = commands.createGroup(editingCommands, this, true);
return dom('input.kf_elabel_input', {type: 'text'},
elem => dom.hide(elem), // Don't display until we've had a chance to resize
kd.value(valueObservable),
dom.autoDispose(commandGroup),
commandGroup.attach(),
dom.on('blur', function() { isEditing(false); }),
dom.on('change', function() { isEditing(false); }),
dom.on('input', function() {
// Resize the textbox whenever user types in it.
_resizeElem(this, contentSizer);
}),
dom.onDispose(elem => {
if (!cancelEdit && valueObservable() !== elem.value) {
setSaveValue(valueObservable, elem.value);
}
}),
dom.defer(function(elem) {
cancelEdit = false;
_resizeElem(elem, contentSizer);
dom.show(elem); // Once resized, display the input
elem.focus();
elem.select();
})
);
})
);
};
function _resizeElem(elem, contentSizer) {
contentSizer.textContent = elem.value;
var rect = contentSizer.getBoundingClientRect();
elem.style.width = Math.ceil(rect.width) + 'px';
}
/**
* Accepts any number of children. If an argument is numeric, it specifies the number of columns
* the next child should occupy (defaults to 1). All columns are equally spaced.
*/
exports.row = function(childOrColSpanArgs) {
var colSpan = 1;
var elem = dom('div.kf_row.flexhbox');
for (var i = 0; i < arguments.length; i++) {
var arg = arguments[i];
if (typeof arg === 'number') {
colSpan = arg;
} else if (typeof arg === 'function') {
arg(elem);
} else if (typeof arg !== 'undefined') {
if (typeof arg === 'string' || Array.isArray(arg)) {
arg = dom('div', arg);
}
arg.style.flex = arg.style.webkitFlex = colSpan + " 1 0px";
elem.appendChild(arg);
colSpan = 1;
}
}
return elem;
};
/**
* Creates a row of help labels. Takes the same arguments as `row()`, but the content is typically
* short descriptive strings. Use it immediately after a `row()` call, with same column-span
* values, to place descriptions under the elements of the row.
*/
exports.helpRow = function(childOrColSpan) {
var elem = exports.row.apply(null, arguments);
elem.classList.add('kf_help_row');
return elem;
};
/**
* Creates a scrollable pane, with a shadow over the top edge.
*/
exports.scrollable = function(contentArgs) {
var elem, shadow;
return [
dom('div.flexnone.kf_scroll_shadow_outer',
shadow = dom('div.kf_scroll_shadow', dom.hide)
),
elem = dom('div.flexitem.kf_scrollable',
dom.on('scroll', function() {
shadow.style.display = (elem.scrollTop > 0 ? '' : 'none');
}),
dom.fwdArgs(arguments, 0)
)
];
};
/**
* Creates a select (or dropdown) widget. The `valueObservable` reflects the value of the selected
* option, `optionArray` is an array (regular or observable) of option values and labels.
* may be either strings (used for both values and labels) or objects with "value", "label", and
* "disabled" properties.
*
* @param {observable} valueObservable - An observable whose value will be set to the value of the
* corresponding option from `optionArray` when the selection changes, or an array of sorted
* values if `options.multiple` is enabled. A string representation of the value is also made
* accessible as in the <option>'s value attribute, and is what would be submitted if, for
* example, the element is used in a submitted form.
* @param {Array<string|Object>} optionArray - Array of options as strings or objects. If string,
* the same value will be used for key (i.e. value) and label. If object, its properties will be
* used to populate `value`, `label`, and `disabled`. A koArray may be used.
* @property {Any} value - Value of the option that, when selected, will be set as the value of
* valueObservable (or included into an array of selected values with `multiple: true`).
* The string representation of this value will also be set on the `<option>` DOM, but
* valueObservable will receive the raw JavaScript value. This should not mutate.
* @property {string|observable} label - Visible label for the option. Can be an observable.
* @property {boolean|observable} disabled - Optional disabled flag. Can be an observable.
*
* @param {Object} options
* @property {Number} [options.size] The number of rows in the select list that
* @property {Boolean} [options.disabled] Whether the select control is disabled.
* @property {Boolean} [options.multiple] Whether the select control supports multiple selection.
* If true, `valueObservable` should be an array of values.
*/
exports.select = function(valueObservable, optionArray, options) {
// Wrap the returned element into a div, since otherwise it doesn't respect
// dimensions as well.
options = options || {};
// Sets elem.value to value. Useful for setting the displayed multiselect value.
var setValue = (elem, value) => {
let valuesSet = new Set(options.multiple ? value : [value]);
for (let option of elem.querySelectorAll('option')) {
option.selected = valuesSet.has(ko.utils.domData.get(option, 'value'));
}
};
return dom('div.kf_elem',
dom('div.kf_select_arrow',
dom('select.kf_select',
pick(options, ['size', 'multiple']),
kd.toggleDisabled(options.disabled || false),
kd.foreach(optionArray, function(option) {
if (!option) {
return null;
}
let value = (typeof option === 'string' ? option : option.value);
let label = (typeof option === 'string' ? option : option.label);
let disabled = (typeof option === 'string' ? false : option.disabled);
return dom(
'option',
{ value }, // To keep older browser tests from breaking, store stringified value in DOM
kd.domData('value', value),
kd.toggleDisabled(disabled),
kd.text(label)
);
}),
// If the optionArray changes, the selected option may become different than the
// option displayed. This is fixed by re-setting elem.value on optionArray changes.
kd.makeBinding(koArray.isKoArray(optionArray) ? optionArray.getObservable() : optionArray,
elem => setValue(elem, valueObservable())),
kd.makeBinding(valueObservable, (elem, value) => setValue(elem, value)),
dom.on('change', function() {
let valuesArray = [];
let optionElements = this.querySelectorAll('option');
for (let i = 0; i < optionElements.length; i++) {
if (optionElements[i].selected) {
let value = ko.utils.domData.get(optionElements[i], 'value');
valuesArray.push(value);
if (!options.multiple) { break; }
}
}
valuesArray.sort();
setSaveValue(valueObservable, options.multiple ? valuesArray : valuesArray[0]);
})
)
)
);
};
/**
* A separator (thin horizontal line).
*/
exports.separator = function() {
return dom('hr.kf_separator');
};
/**
* Creates a set of tabs for the top of a pane. It takes no arguments, and should be followed by
* `topTab()` calls under the same DOM element.
* @param {Observable} optObservable The observable for the index of the selected tab, will be
* created if omitted.
*/
exports.topTabs = function(optObservable) {
return _initTabs(optObservable, '.kf_top_tab_label',
dom('div.flexvbox.kf_top_tabs',
dom('div.flexhbox.flexnone.kf_top_tab_labels'),
dom('div.flexitem.kf_top_tab_container')
)
);
};
/**
* Adds a tab to the `topTabs` container created previously under the same DOM element. The
* `label` is a label or Node for the tab label; the rest is the content of the tab.
* The content is created once, but is hidden when a different tab is selected.
*/
exports.topTab = function(label, moreContentArgs) {
return _addTab('.kf_top_tabs',
dom('div.kf_top_tab_label.flexitem', label),
dom('div.kf_top_tab_content.flexvbox', dom.fwdArgs(arguments, 1)));
};
/**
* Helper function for creating a set of tabs.
* @param {Observable} optObservable The observable for the index of the selected tab, will be
* created if omitted.
* @param {String} labelSelector The selector (e.g. ".className") for the tab label elements that
* will be given to _addTab.
* @param {Node} elem The tabs container element. Its first child must be the container for the
* labels, and its last child, the container for the content panes.
*/
function _initTabs(optObservable, labelSelector, elem) {
var selectedTab = optObservable || ko.observable(0);
G.$(elem).on('click', labelSelector, function() {
selectedTab(dom.childIndex(this));
});
ko.utils.domData.set(elem, 'kfSelectedTab', selectedTab);
return elem;
}
/**
* Helper function for adding a tab to a set of tabs created with _initTabs.
* @param {String} tabsSelector The selector of the tabs container that was given to _initTab.
* It's needed to find that element.
* @param {Node} labelElem The label element to add to the container of labels.
* @param {Node} contentElem The content element to add to the container of content panes.
*/
function _addTab(tabsSelector, labelElem, contentElem) {
return function(elem) {
var tabsEl = dom.findLastChild(elem, tabsSelector);
if (!tabsEl) {
console.log("koForm: Attempting to add tab without an existing tabs container");
return;
}
var selectedTab = ko.utils.domData.get(tabsEl, 'kfSelectedTab');
var labels = tabsEl.firstChild;
var container = tabsEl.lastChild;
var index = labels.childNodes.length;
var isSelected = ko.computed(function() { return selectedTab() === index; });
// These methods are indended to be used as arguments to dom() function, so they return a
// function that should be applied to the target element.
kd.toggleClass('active', isSelected)(labelElem);
dom.autoDispose(labelElem, isSelected);
kd.show(isSelected)(contentElem);
labels.appendChild(labelElem);
container.appendChild(contentElem);
};
}