/** * 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); }; }