diff --git a/docs/source/examples/Widget TagsInput.ipynb b/docs/source/examples/Widget TagsInput.ipynb new file mode 100644 index 00000000000..736818e7312 --- /dev/null +++ b/docs/source/examples/Widget TagsInput.ipynb @@ -0,0 +1,173 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# TagsInput widget" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from ipywidgets import TagsInput\n", + "\n", + "tags = TagsInput(value=['pizza', 'burger', 'fries', 'nuggets', 'potatoes', 'tomatoes'])\n", + "tags.tag_style = 'primary'\n", + "\n", + "tags" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "tags.tag_style = 'warning'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "tags1 = TagsInput(value=['pizza', 'burger'])\n", + "tags1.tag_style = 'primary'\n", + "tags1.allowed_tags = ['pizza', 'burger', 'fries', 'nuggets', 'potatoes', 'tomatoes', 'ketchup']\n", + "\n", + "tags1" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# ColorsInput widget" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from ipywidgets import ColorsInput" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "colortags = ColorsInput(value=['red', 'green', 'rgb(200, 50, 200)', '#32a852'])\n", + "colortags" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "colortags" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "colortags1 = ColorsInput(value=['red', 'green'])\n", + "colortags1.allowed_tags = ['red', 'green', 'blue', 'yellow', 'purple']\n", + "\n", + "colortags1" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# NumbersInput widget" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from ipywidgets import FloatsInput, IntsInput" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "floatsinput = FloatsInput(value=[1.3, 4.56, 78.90])\n", + "floatsinput.tag_style = 'info'\n", + "\n", + "floatsinput" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "floatsinput.format = '.2f'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "intsinput = IntsInput(value=[1, 4, 22], min=0, max=23)\n", + "intsinput.tag_style = 'danger'\n", + "\n", + "intsinput" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "intsinput.format = '.2e'" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.1" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/ipywidgets/widgets/__init__.py b/ipywidgets/widgets/__init__.py index a7b5687b454..cd91177e11a 100644 --- a/ipywidgets/widgets/__init__.py +++ b/ipywidgets/widgets/__init__.py @@ -24,6 +24,7 @@ from .widget_link import jslink, jsdlink from .widget_layout import Layout from .widget_media import Image, Video, Audio +from .widget_tagsinput import TagsInput, ColorsInput, FloatsInput, IntsInput from .widget_style import Style from .widget_templates import TwoByTwoLayout, AppLayout, GridspecLayout from .widget_upload import FileUpload diff --git a/ipywidgets/widgets/widget_tagsinput.py b/ipywidgets/widgets/widget_tagsinput.py new file mode 100644 index 00000000000..edda66b4d23 --- /dev/null +++ b/ipywidgets/widgets/widget_tagsinput.py @@ -0,0 +1,105 @@ +# Copyright(c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +"""TagsInput class. + +Represents a list of tags. +""" + +from traitlets import ( + CaselessStrEnum, CInt, CFloat, Bool, Unicode, List, TraitError, validate +) + +from .widget_description import DescriptionWidget +from .valuewidget import ValueWidget +from .widget_core import CoreWidget +from .widget import register +from .trait_types import Color, NumberFormat + + +class TagsInputBase(DescriptionWidget, ValueWidget, CoreWidget): + _model_name = Unicode('TagsInputBaseModel').tag(sync=True) + value = List().tag(sync=True) + allowed_tags = List().tag(sync=True) + allow_duplicates = Bool(True).tag(sync=True) + + @validate('value') + def _validate_value(self, proposal): + if ('' in proposal['value']): + raise TraitError('The value of a TagsInput widget cannot contain blank strings') + + if len(self.allowed_tags) == 0: + return proposal['value'] + + for tag_value in proposal['value']: + if tag_value not in self.allowed_tags: + raise TraitError('Tag value {} is not allowed, allowed tags are {}'.format(tag_value, self.allowed_tags)) + + return proposal['value'] + + +@register +class TagsInput(TagsInputBase): + """ + List of string tags + """ + _model_name = Unicode('TagsInputModel').tag(sync=True) + _view_name = Unicode('TagsInputView').tag(sync=True) + + value = List(Unicode(), help='List of string tags').tag(sync=True) + tag_style = CaselessStrEnum( + values=['primary', 'success', 'info', 'warning', 'danger', ''], default_value='', + help="""Use a predefined styling for the tags.""").tag(sync=True) + + +@register +class ColorsInput(TagsInputBase): + """ + List of color tags + """ + _model_name = Unicode('ColorsInputModel').tag(sync=True) + _view_name = Unicode('ColorsInputView').tag(sync=True) + + value = List(Color(), help='List of string tags').tag(sync=True) + + +class NumbersInputBase(TagsInput): + _model_name = Unicode('NumbersInputBaseModel').tag(sync=True) + min = CFloat(default_value=None, allow_none=True).tag(sync=True) + max = CFloat(default_value=None, allow_none=True).tag(sync=True) + + @validate('value') + def _validate_numbers(self, proposal): + for tag_value in proposal['value']: + if self.min is not None and tag_value < self.min: + raise TraitError('Tag value {} should be >= {}'.format(tag_value, self.min)) + if self.max is not None and tag_value > self.max: + raise TraitError('Tag value {} should be <= {}'.format(tag_value, self.max)) + + return proposal['value'] + + +@register +class FloatsInput(NumbersInputBase): + """ + List of float tags + """ + _model_name = Unicode('FloatsInputModel').tag(sync=True) + _view_name = Unicode('FloatsInputView').tag(sync=True) + + value = List(CFloat(), help='List of float tags').tag(sync=True) + format = NumberFormat('.1f').tag(sync=True) + + +@register +class IntsInput(NumbersInputBase): + """ + List of int tags + """ + _model_name = Unicode('IntsInputModel').tag(sync=True) + _view_name = Unicode('IntsInputView').tag(sync=True) + + value = List(CInt(), help='List of int tags').tag(sync=True) + format = NumberFormat('.3g').tag(sync=True) + min = CInt(default_value=None, allow_none=True).tag(sync=True) + max = CInt(default_value=None, allow_none=True).tag(sync=True) diff --git a/packages/controls/css/widgets-base.css b/packages/controls/css/widgets-base.css index b7990894f49..3a1b35462b8 100644 --- a/packages/controls/css/widgets-base.css +++ b/packages/controls/css/widgets-base.css @@ -118,6 +118,144 @@ flex-direction: column; } +/* General Tags Styling */ + +.jupyter-widget-tagsinput { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + overflow: auto; + + cursor: text; +} + +.jupyter-widget-tag { + padding-left: 10px; + padding-right: 10px; + padding-top: 0px; + padding-bottom: 0px; + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + font-size: var(--jp-widgets-font-size); + + height: calc(var(--jp-widgets-inline-height) - 2px); + border: 0px solid; + line-height: calc(var(--jp-widgets-inline-height) - 2px); + box-shadow: none; + + color: var(--jp-ui-font-color1); + background-color: var(--jp-layout-color2); + border-color: var(--jp-border-color2); + border: none; + user-select: none; + + cursor: grab; + transition: margin-left 200ms; + margin: 1px 1px 1px 1px; +} + +.jupyter-widget-tag.mod-active { + /* MD Lite 4dp shadow */ + box-shadow: 0 4px 5px 0 rgba(0, 0, 0, var(--md-shadow-key-penumbra-opacity)), + 0 1px 10px 0 rgba(0, 0, 0, var(--md-shadow-ambient-shadow-opacity)), + 0 2px 4px -1px rgba(0, 0, 0, var(--md-shadow-key-umbra-opacity)); + color: var(--jp-ui-font-color1); + background-color: var(--jp-layout-color3); +} + +.jupyter-widget-colortag { + color: var(--jp-inverse-ui-font-color1); +} + +.jupyter-widget-colortag.mod-active { + color: var(--jp-inverse-ui-font-color0); +} + +.jupyter-widget-taginput { + color: var(--jp-ui-font-color0); + background-color: var(--jp-layout-color0); + + cursor: text; + text-align: left; +} + +.jupyter-widget-taginput:focus { + outline: none; +} + +.jupyter-widget-tag-close { + margin-left: var(--jp-widgets-inline-margin); + padding: 2px 0px 2px 2px; +} + +.jupyter-widget-tag-close:hover { + cursor: pointer; +} + +/* Tag "Primary" Styling */ + +.jupyter-widget-tag.mod-primary { + color: var(--jp-inverse-ui-font-color1); + background-color: var(--jp-brand-color1); +} + +.jupyter-widget-tag.mod-primary.mod-active { + color: var(--jp-inverse-ui-font-color0); + background-color: var(--jp-brand-color0); +} + +/* Tag "Success" Styling */ + +.jupyter-widget-tag.mod-success { + color: var(--jp-inverse-ui-font-color1); + background-color: var(--jp-success-color1); +} + +.jupyter-widget-tag.mod-success.mod-active { + color: var(--jp-inverse-ui-font-color0); + background-color: var(--jp-success-color0); +} + +/* Tag "Info" Styling */ + +.jupyter-widget-tag.mod-info { + color: var(--jp-inverse-ui-font-color1); + background-color: var(--jp-info-color1); +} + +.jupyter-widget-tag.mod-info.mod-active { + color: var(--jp-inverse-ui-font-color0); + background-color: var(--jp-info-color0); +} + +/* Tag "Warning" Styling */ + +.jupyter-widget-tag.mod-warning { + color: var(--jp-inverse-ui-font-color1); + background-color: var(--jp-warn-color1); +} + +.jupyter-widget-tag.mod-warning.mod-active { + color: var(--jp-inverse-ui-font-color0); + background-color: var(--jp-warn-color0); +} + +/* Tag "Danger" Styling */ + +.jupyter-widget-tag.mod-danger { + color: var(--jp-inverse-ui-font-color1); + background-color: var(--jp-error-color1); +} + +.jupyter-widget-tag.mod-danger.mod-active { + color: var(--jp-inverse-ui-font-color0); + background-color: var(--jp-error-color0); +} + /* General Button Styling */ .jupyter-button { diff --git a/packages/controls/package-lock.json b/packages/controls/package-lock.json new file mode 100644 index 00000000000..c30f3b513e4 --- /dev/null +++ b/packages/controls/package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "@jupyter-widgets/controls", + "version": "1.5.3", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/d3-color": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-1.2.2.tgz", + "integrity": "sha512-6pBxzJ8ZP3dYEQ4YjQ+NVbQaOflfgXq/JbDiS99oLobM2o72uAST4q6yPxHv6FOTCRC/n35ktuo8pvw/S4M7sw==" + }, + "@types/d3-format": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.3.1.tgz", + "integrity": "sha512-KAWvReOKMDreaAwOjdfQMm0HjcUMlQG47GwqdVKgmm20vTd2pucj0a70c3gUSHrnsmo6H2AMrkBsZU2UhJLq8A==", + "dev": true + }, + "d3-color": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.0.tgz", + "integrity": "sha512-TzNPeJy2+iEepfiL92LAAB7fvnp/dV2YwANPVHdDWmYMm23qIJBYww3qT8I8C1wXrmrg4UWs7BKc2tKIgyjzHg==" + } + } +} diff --git a/packages/controls/package.json b/packages/controls/package.json index 0c486420fa3..d174b32db3d 100644 --- a/packages/controls/package.json +++ b/packages/controls/package.json @@ -40,12 +40,14 @@ "@lumino/messaging": "^1.3.3", "@lumino/signaling": "^1.3.5", "@lumino/widgets": "^1.11.1", + "d3-color": "^1.4.0", "d3-format": "^1.3.0", "jquery": "^3.1.1", "nouislider": "^14.1.1" }, "devDependencies": { "@jupyterlab/services": "^5.0.2", + "@types/d3-color": "^1.2.2", "@types/d3-format": "^1.3.1", "@types/expect.js": "^0.3.29", "@types/mathjax": "^0.0.35", diff --git a/packages/controls/src/index.ts b/packages/controls/src/index.ts index 56e455553c1..d7134b92c79 100644 --- a/packages/controls/src/index.ts +++ b/packages/controls/src/index.ts @@ -17,6 +17,7 @@ export * from './widget_float'; export * from './widget_controller'; export * from './widget_selection'; export * from './widget_selectioncontainer'; +export * from './widget_tagsinput'; export * from './widget_string'; export * from './widget_description'; export * from './widget_upload'; diff --git a/packages/controls/src/widget_tagsinput.ts b/packages/controls/src/widget_tagsinput.ts new file mode 100644 index 00000000000..8e6f9a976a2 --- /dev/null +++ b/packages/controls/src/widget_tagsinput.ts @@ -0,0 +1,844 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import * as d3Color from 'd3-color'; +import * as d3Format from 'd3-format'; + +import { CoreDOMWidgetModel } from './widget_core'; + +import { DOMWidgetView, Dict, uuid } from '@jupyter-widgets/base'; + +/** + * Returns a new string after removing any leading and trailing whitespaces. + * The original string is left unchanged. + */ +function trim(value: string): string { + return value.replace(/^\s+|\s+$/g, ''); +} + +/** + * Clamp a number between min and max and return the result. + */ +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +/** + * Remove children from an HTMLElement + */ +function removeChildren(el: HTMLElement) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } +} + +/** + * Selection class which keeps track on selected indices. + */ +class Selection { + constructor(start: number, dx: number, max: number) { + this.start = start; + this.dx = dx; + this.max = max; + } + + /** + * Check if a given index is currently selected. + */ + isSelected(index: number): boolean { + let min: number; + let max: number; + if (this.dx >= 0) { + min = this.start; + max = this.start + this.dx; + } else { + min = this.start + this.dx; + max = this.start; + } + return min <= index && index < max; + } + + /** + * Update selection + */ + updateSelection(dx: number): void { + this.dx += dx; + + if (this.start + this.dx > this.max) { + this.dx = this.max - this.start; + } + if (this.start + this.dx < 0) { + this.dx = -this.start; + } + } + + private start: number; + private dx: number; + private max: number; +} + +class TagsInputBaseModel extends CoreDOMWidgetModel { + defaults(): Backbone.ObjectHash { + return { + ...super.defaults(), + value: [], + allowed_tags: null, + allow_duplicates: true + }; + } +} + +abstract class TagsInputBaseView extends DOMWidgetView { + /** + * Called when view is rendered. + */ + render() { + super.render(); + + this.el.classList.add('jupyter-widgets'); + this.el.classList.add('jupyter-widget-tagsinput'); + + this.taginputWrapper = document.createElement('div'); + + // The taginput is not displayed until the user focuses on the widget + this.taginputWrapper.style.display = 'none'; + + this.datalistID = uuid(); + + this.taginput = document.createElement('input'); + this.taginput.classList.add('jupyter-widget-tag'); + this.taginput.classList.add('jupyter-widget-taginput'); + this.taginput.setAttribute('list', this.datalistID); + + this.autocompleteList = document.createElement('datalist'); + this.autocompleteList.id = this.datalistID; + + this.updateAutocomplete(); + this.model.on('change:allowed_tags', this.updateAutocomplete.bind(this)); + + this.taginputWrapper.appendChild(this.taginput); + this.taginputWrapper.appendChild(this.autocompleteList); + + this.el.onclick = this.focus.bind(this); + this.el.ondrop = (event: DragEvent) => { + // Put the tag at the end of the list if there is no currently hovered tag + const index = + this.hoveredTagIndex == null ? this.tags.length : this.hoveredTagIndex; + this.ondrop(event, index); + }; + this.el.ondragover = this.ondragover.bind(this); + + this.taginput.onchange = this.handleValueAdded.bind(this); + this.taginput.oninput = this.resizeInput.bind(this); + this.taginput.onkeydown = this.handleKeyEvent.bind(this); + this.taginput.onblur = this.loseFocus.bind(this); + this.resizeInput(); + + this.inputIndex = this.model.get('value').length; + + this.selection = null; + this.preventLoosingFocus = false; + + this.update(); + } + + /** + * Update the contents of this view + * + * Called when the model is changed. The model may have been + * changed by another view or by a state update from the back-end. + */ + update() { + // Prevent hiding the input element and clearing the selection when updating everything + this.preventLoosingFocus = true; + + removeChildren(this.el); + this.tags = []; + + const value: Array = this.model.get('value'); + for (const idx in value) { + const index = parseInt(idx); + + const tag = this.createTag( + value[index], + index, + this.selection != null && this.selection.isSelected(index) + ); + + // Drag and drop + tag.draggable = true; + tag.ondragstart = ((index: number, value: any) => { + return (event: DragEvent) => { + this.ondragstart(event, index, value, this.model.model_id); + }; + })(index, value[index]); + tag.ondrop = ((index: number) => { + return (event: DragEvent) => { + this.ondrop(event, index); + }; + })(index); + tag.ondragover = this.ondragover.bind(this); + tag.ondragenter = ((index: number) => { + return (event: DragEvent) => { + this.ondragenter(event, index); + }; + })(index); + tag.ondragend = this.ondragend.bind(this); + + this.tags.push(tag); + this.el.appendChild(tag); + } + + this.el.insertBefore( + this.taginputWrapper, + this.el.children[this.inputIndex] + ); + + this.preventLoosingFocus = false; + + return super.update(); + } + + /** + * Update the auto-completion list + */ + updateAutocomplete() { + removeChildren(this.autocompleteList); + + const allowedTags = this.model.get('allowed_tags'); + + for (const tag of allowedTags) { + const option = document.createElement('option'); + option.value = tag; + this.autocompleteList.appendChild(option); + } + } + + /** + * Update the tags, called when the selection has changed and we need to update the tags CSS + */ + updateTags() { + const value: Array = this.model.get('value'); + + for (const idx in this.tags) { + const index = parseInt(idx); + + this.updateTag( + this.tags[index], + value[index], + index, + this.selection != null && this.selection.isSelected(index) + ); + } + } + + /** + * Handle a new value is added from the input element + */ + handleValueAdded(event: Event) { + const newTagValue = trim(this.taginput.value); + const tagIndex = this.inputIndex; + + if (newTagValue == '') { + return; + } + + this.inputIndex++; + + const tagAdded = this.addTag(tagIndex, newTagValue); + + if (tagAdded) { + // Clear the input and keep focus on it allowing the user to add more tags + this.taginput.value = ''; + this.resizeInput(); + this.focus(); + } + } + + /** + * Add a new tag with a value of `tagValue` at the `index` position + * Return true if the tag was correctly added, false otherwise + */ + addTag(index: number, tagValue: string): boolean { + const value: Array = this.model.get('value'); + + let newTagValue: any; + try { + newTagValue = this.validateValue(tagValue); + } catch (error) { + return false; + } + + const allowedTagValues = this.model.get('allowed_tags'); + if (allowedTagValues.length && !allowedTagValues.includes(newTagValue)) { + // Do nothing for now, maybe show a proper error message? + return false; + } + + if (!this.model.get('allow_duplicates') && value.includes(newTagValue)) { + // Do nothing for now, maybe add an animation to highlight the tag? + return false; + } + + // Clearing the current selection before setting the new value + this.selection = null; + + // Making a copy so that backbone sees the change, and insert the new tag + const newValue = [...value]; + newValue.splice(index, 0, newTagValue); + + this.model.set('value', newValue); + this.model.save_changes(); + + return true; + } + + /** + * Resize the input element + */ + resizeInput() { + const size = this.taginput.value.length + 1; + this.taginput.setAttribute('size', String(size)); + } + + /** + * Handle key events on the input element + */ + handleKeyEvent(event: KeyboardEvent) { + const valueLength = this.model.get('value').length; + + // Do nothing if the user is typing something + if (this.taginput.value.length) { + return; + } + + const currentElement: number = this.inputIndex; + switch (event.key) { + case 'ArrowLeft': + if (event.ctrlKey && event.shiftKey) { + this.select(currentElement, -currentElement); + } + if (!event.ctrlKey && event.shiftKey) { + this.select(currentElement, -1); + } + + if (event.ctrlKey) { + this.inputIndex = 0; + } else { + this.inputIndex--; + } + break; + case 'ArrowRight': + if (event.ctrlKey && event.shiftKey) { + this.select(currentElement, valueLength - currentElement); + } + if (!event.ctrlKey && event.shiftKey) { + this.select(currentElement, 1); + } + + if (event.ctrlKey) { + this.inputIndex = valueLength; + } else { + this.inputIndex++; + } + break; + case 'Backspace': + if (this.selection) { + this.removeSelectedTags(); + } else { + this.removeTag(this.inputIndex - 1); + } + break; + case 'Delete': + if (this.selection) { + this.removeSelectedTags(); + } else { + this.removeTag(this.inputIndex); + } + break; + default: + // Do nothing by default + return; + break; + } + + // Reset selection is shift key is not pressed + if (!event.shiftKey) { + this.selection = null; + } + + this.inputIndex = clamp(this.inputIndex, 0, valueLength); + + this.update(); + this.focus(); + } + + /** + * Function that gets called when a tag with a given `value` is being dragged. + */ + ondragstart(event: DragEvent, index: number, tagValue: any, origin: string) { + if (event.dataTransfer == null) { + return; + } + event.dataTransfer.setData('index', String(index)); + event.dataTransfer.setData('tagValue', String(tagValue)); + event.dataTransfer.setData('origin', origin); + } + + /** + * Function that gets called when a tag has been dragged on the tag at the `index` position. + */ + ondrop(event: DragEvent, index: number) { + if (event.dataTransfer == null) { + return; + } + event.preventDefault(); + event.stopPropagation(); + + const draggedTagValue: string = event.dataTransfer.getData('tagValue'); + const draggedTagindex: number = parseInt( + event.dataTransfer.getData('index') + ); + const sameOrigin = + event.dataTransfer.getData('origin') == this.model.model_id; + + // If something else than a tag was dropped, draggedTagindex should be NaN + if (isNaN(draggedTagindex)) { + return; + } + + // If it's the same origin, the drag and drop results in a reordering + if (sameOrigin) { + const value: Array = this.model.get('value'); + + const newValue = [...value]; + + // If the old position is on the left of the new position, we need to re-index the new position + // after removing the tag at the old position + if (draggedTagindex < index) { + index--; + } + + newValue.splice(draggedTagindex, 1); // Removing at the old position + newValue.splice(index, 0, draggedTagValue); // Adding at the new one + + this.model.set('value', newValue); + this.model.save_changes(); + + return; + } + + // Else we add a new tag with the given draggedTagValue + this.addTag(index, draggedTagValue); + } + + ondragover(event: DragEvent) { + // This is needed for the drag and drop to work + event.preventDefault(); + } + + ondragenter(event: DragEvent, index: number) { + if (this.hoveredTag != null && this.hoveredTag != this.tags[index]) { + this.hoveredTag.style.marginLeft = '1px'; + } + + this.hoveredTag = this.tags[index]; + this.hoveredTagIndex = index; + this.hoveredTag.style.marginLeft = '30px'; + } + + ondragend() { + if (this.hoveredTag != null) { + this.hoveredTag.style.marginLeft = '1px'; + } + this.hoveredTag = null; + this.hoveredTagIndex = null; + } + + /** + * Select tags from `start` to `start + dx` not included. + */ + select(start: number, dx: number) { + const valueLength = this.model.get('value').length; + + if (!this.selection) { + this.selection = new Selection(start, dx, valueLength); + } else { + this.selection.updateSelection(dx); + } + } + + /** + * Remove all the selected tags. + */ + removeSelectedTags() { + const value: Array = [...this.model.get('value')]; + const valueLength = value.length; + + // It is simpler to remove from right to left + for (let idx = valueLength - 1; idx >= 0; idx--) { + if (this.selection != null && this.selection.isSelected(idx)) { + value.splice(idx, 1); + + // Move the input to the left if we remove a tag that is before the input + if (idx < this.inputIndex) { + this.inputIndex--; + } + } + } + + this.model.set('value', value); + this.model.save_changes(); + } + + /** + * Remove a tag given its index in the list + */ + removeTag(tagIndex: number) { + const value: Array = [...this.model.get('value')]; + + value.splice(tagIndex, 1); + + // Move the input to the left if we remove a tag that is before the input + if (tagIndex < this.inputIndex) { + this.inputIndex--; + } + + this.model.set('value', value); + this.model.save_changes(); + } + + /** + * Focus on the input element + */ + focus() { + this.taginputWrapper.style.display = 'inline-block'; + this.taginput.focus(); + } + + /** + * Lose focus on the input element + */ + loseFocus() { + if (this.preventLoosingFocus) { + return; + } + + // Only hide the input if we have tags displayed + if (this.model.get('value').length) { + this.taginputWrapper.style.display = 'none'; + } + + this.selection = null; + this.updateTags(); + } + + /** + * The default tag name. + * + * #### Notes + * This is a read-only attribute. + */ + get tagName() { + // We can't make this an attribute with a default value + // since it would be set after it is needed in the + // constructor. + return 'div'; + } + + /** + * Validate an input tag typed by the user, returning the correct tag type. This should be overridden in subclasses. + */ + validateValue(value: string): any { + return value; + } + + abstract createTag(value: any, index: number, selected: boolean): HTMLElement; + abstract updateTag( + tag: HTMLElement, + value: any, + index: number, + selected: boolean + ): void; + + el: HTMLDivElement; + taginputWrapper: HTMLDivElement; + taginput: HTMLInputElement; + autocompleteList: HTMLDataListElement; + tags: HTMLElement[]; + hoveredTag: HTMLElement | null = null; + hoveredTagIndex: number | null = null; + datalistID: string; + inputIndex: number; + selection: null | Selection; + preventLoosingFocus: boolean; + + model: TagsInputBaseModel; +} + +export class TagsInputModel extends TagsInputBaseModel { + defaults(): Backbone.ObjectHash { + return { + ...super.defaults(), + value: [], + tag_style: '', + _view_name: 'TagsInputView', + _model_name: 'TagsInputModel' + }; + } +} + +export class TagsInputView extends TagsInputBaseView { + /** + * Create the string tag + */ + createTag(value: string, index: number, selected: boolean): HTMLDivElement { + const tag = document.createElement('div'); + const style: string = this.model.get('tag_style'); + + tag.classList.add('jupyter-widget-tag'); + tag.classList.add(TagsInputView.class_map[style]); + + if (selected) { + tag.classList.add('mod-active'); + } + + tag.appendChild(document.createTextNode(this.getTagText(value))); + + const i = document.createElement('i'); + i.classList.add('fa'); + i.classList.add('fa-times'); + i.classList.add('jupyter-widget-tag-close'); + tag.appendChild(i); + + i.onmousedown = ((index: number) => { + return () => { + this.removeTag(index); + this.loseFocus(); + }; + })(index); + + return tag; + } + + /** + * Returns the text that should be displayed in the tag element + */ + getTagText(value: string) { + return value; + } + + /** + * Update a given tag + */ + updateTag( + tag: HTMLDivElement, + value: any, + index: number, + selected: boolean + ): void { + if (selected) { + tag.classList.add('mod-active'); + } else { + tag.classList.remove('mod-active'); + } + } + + model: TagsInputModel; + + static class_map: Dict = { + primary: 'mod-primary', + success: 'mod-success', + info: 'mod-info', + warning: 'mod-warning', + danger: 'mod-danger' + }; +} + +export class ColorsInputModel extends TagsInputBaseModel { + defaults(): Backbone.ObjectHash { + return { + ...super.defaults(), + value: [], + _view_name: 'ColorsInputView', + _model_name: 'ColorsInputModel' + }; + } +} + +export class ColorsInputView extends TagsInputBaseView { + /** + * Create the Color tag + */ + createTag(value: string, index: number, selected: boolean): HTMLDivElement { + const tag = document.createElement('div'); + const color = value; + const darkerColor: string = d3Color + .color(value)! + .darker() + .toString(); + + tag.classList.add('jupyter-widget-tag'); + tag.classList.add('jupyter-widget-colortag'); + + if (!selected) { + tag.style.backgroundColor = color; + } else { + tag.classList.add('mod-active'); + tag.style.backgroundColor = darkerColor; + } + + const i = document.createElement('i'); + i.classList.add('fa'); + i.classList.add('fa-times'); + i.classList.add('jupyter-widget-tag-close'); + tag.appendChild(i); + + i.onmousedown = ((index: number) => { + return () => { + this.removeTag(index); + this.loseFocus(); + }; + })(index); + + return tag; + } + + /** + * Update a given tag + */ + updateTag( + tag: HTMLDivElement, + value: any, + index: number, + selected: boolean + ): void { + const color = value; + const darkerColor: string = d3Color + .color(value)! + .darker() + .toString(); + + if (!selected) { + tag.classList.remove('mod-active'); + tag.style.backgroundColor = color; + } else { + tag.classList.add('mod-active'); + tag.style.backgroundColor = darkerColor; + } + } + + /** + * Validate an input tag typed by the user, returning the correct tag type. This should be overridden in subclasses. + */ + validateValue(value: string): any { + if (d3Color.color(value) == null) { + throw value + ' is not a valid Color'; + } + + return value; + } + + model: ColorsInputModel; +} + +abstract class NumbersInputModel extends TagsInputModel { + defaults(): Backbone.ObjectHash { + return { + ...super.defaults(), + min: null, + max: null + }; + } +} + +abstract class NumbersInputView extends TagsInputView { + render() { + // Initialize text formatter + this.model.on('change:format', () => { + this.formatter = d3Format.format(this.model.get('format')); + this.update(); + }); + this.formatter = d3Format.format(this.model.get('format')); + + super.render(); + } + + /** + * Returns the text that should be displayed in the tag element + */ + getTagText(value: string) { + return this.formatter(this.parseNumber(value)); + } + + /** + * Validate an input tag typed by the user, returning the correct tag type. This should be overridden in subclasses. + */ + validateValue(value: string): any { + const parsed = this.parseNumber(value); + const min: number | null = this.model.get('min'); + const max: number | null = this.model.get('max'); + + if ( + isNaN(parsed) || + (min != null && parsed < min) || + (max != null && parsed > max) + ) { + throw value + + ' is not a valid number, it should be in the range [' + + min + + ', ' + + max + + ']'; + } + + return parsed; + } + + abstract parseNumber(value: string): number; + + formatter: (value: number) => string; +} + +export class FloatsInputModel extends NumbersInputModel { + defaults(): Backbone.ObjectHash { + return { + ...super.defaults(), + _view_name: 'FloatsInputView', + _model_name: 'FloatsInputModel', + format: '.1f' + }; + } +} + +export class FloatsInputView extends NumbersInputView { + parseNumber(value: string): number { + return parseFloat(value); + } + + model: FloatsInputModel; +} + +export class IntsInputModel extends NumbersInputModel { + defaults(): Backbone.ObjectHash { + return { + ...super.defaults(), + _view_name: 'IntsInputView', + _model_name: 'IntsInputModel', + format: '.3g' + }; + } +} + +export class IntsInputView extends NumbersInputView { + parseNumber(value: string): number { + const int = parseInt(value); + if (int != parseFloat(value)) { + throw value + ' should be an integer'; + } + + return int; + } + + model: IntsInputModel; +} diff --git a/packages/html-manager/src/libembed-amd.ts b/packages/html-manager/src/libembed-amd.ts index 2d63c908e70..7c8ae1e3143 100644 --- a/packages/html-manager/src/libembed-amd.ts +++ b/packages/html-manager/src/libembed-amd.ts @@ -10,8 +10,7 @@ let onlyCDN = false; const scripts = document.getElementsByTagName('script'); Array.prototype.forEach.call(scripts, (script: HTMLScriptElement) => { cdn = script.getAttribute('data-jupyter-widgets-cdn') || cdn; - onlyCDN = - onlyCDN || script.hasAttribute('data-jupyter-widgets-cdn-only'); + onlyCDN = onlyCDN || script.hasAttribute('data-jupyter-widgets-cdn-only'); }); /** diff --git a/packages/schema/jupyterwidgetmodels.latest.md b/packages/schema/jupyterwidgetmodels.latest.md index 3386fde06e1..10b7172b726 100644 --- a/packages/schema/jupyterwidgetmodels.latest.md +++ b/packages/schema/jupyterwidgetmodels.latest.md @@ -238,6 +238,26 @@ Attribute | Type | Default | Help `tooltip` | `null` or string | `null` | A tooltip caption. `value` | string | `'black'` | The color value. +### ColorsInputModel (@jupyter-widgets/controls, 2.0.0); ColorsInputView (@jupyter-widgets/controls, 2.0.0) + +Attribute | Type | Default | Help +-----------------|------------------|------------------|---- +`_dom_classes` | array of string | `[]` | CSS classes applied to widget DOM element +`_model_module` | string | `'@jupyter-widgets/controls'` | +`_model_module_version` | string | `'2.0.0'` | +`_model_name` | string | `'ColorsInputModel'` | +`_view_module` | string | `'@jupyter-widgets/controls'` | +`_view_module_version` | string | `'2.0.0'` | +`_view_name` | string | `'ColorsInputView'` | +`allow_duplicates` | boolean | `true` | +`allowed_tags` | array | `[]` | +`description` | string | `''` | Description of the control. +`layout` | reference to Layout widget | reference to new instance | +`style` | reference to DescriptionStyle widget | reference to new instance | Styling customizations +`tabbable` | `null` or boolean | `null` | Is widget tabbable? +`tooltip` | `null` or string | `null` | A tooltip caption. +`value` | array | `[]` | List of string tags + ### ComboboxModel (@jupyter-widgets/controls, 2.0.0); ComboboxView (@jupyter-widgets/controls, 2.0.0) Attribute | Type | Default | Help @@ -542,6 +562,30 @@ Attribute | Type | Default | Help `tooltip` | `null` or string | `null` | A tooltip caption. `value` | number (float) | `0.0` | Float value +### FloatsInputModel (@jupyter-widgets/controls, 2.0.0); FloatsInputView (@jupyter-widgets/controls, 2.0.0) + +Attribute | Type | Default | Help +-----------------|------------------|------------------|---- +`_dom_classes` | array of string | `[]` | CSS classes applied to widget DOM element +`_model_module` | string | `'@jupyter-widgets/controls'` | +`_model_module_version` | string | `'2.0.0'` | +`_model_name` | string | `'FloatsInputModel'` | +`_view_module` | string | `'@jupyter-widgets/controls'` | +`_view_module_version` | string | `'2.0.0'` | +`_view_name` | string | `'FloatsInputView'` | +`allow_duplicates` | boolean | `true` | +`allowed_tags` | array | `[]` | +`description` | string | `''` | Description of the control. +`format` | string | `'.1f'` | +`layout` | reference to Layout widget | reference to new instance | +`max` | `null` or number (float) | `null` | +`min` | `null` or number (float) | `null` | +`style` | reference to DescriptionStyle widget | reference to new instance | Styling customizations +`tabbable` | `null` or boolean | `null` | Is widget tabbable? +`tag_style` | string (one of `'primary'`, `'success'`, `'info'`, `'warning'`, `'danger'`, `''`) | `''` | Use a predefined styling for the tags. +`tooltip` | `null` or string | `null` | A tooltip caption. +`value` | array | `[]` | List of float tags + ### GridBoxModel (@jupyter-widgets/controls, 2.0.0); GridBoxView (@jupyter-widgets/controls, 2.0.0) Attribute | Type | Default | Help @@ -728,6 +772,30 @@ Attribute | Type | Default | Help `tooltip` | `null` or string | `null` | A tooltip caption. `value` | number (integer) | `0` | Int value +### IntsInputModel (@jupyter-widgets/controls, 2.0.0); IntsInputView (@jupyter-widgets/controls, 2.0.0) + +Attribute | Type | Default | Help +-----------------|------------------|------------------|---- +`_dom_classes` | array of string | `[]` | CSS classes applied to widget DOM element +`_model_module` | string | `'@jupyter-widgets/controls'` | +`_model_module_version` | string | `'2.0.0'` | +`_model_name` | string | `'IntsInputModel'` | +`_view_module` | string | `'@jupyter-widgets/controls'` | +`_view_module_version` | string | `'2.0.0'` | +`_view_name` | string | `'IntsInputView'` | +`allow_duplicates` | boolean | `true` | +`allowed_tags` | array | `[]` | +`description` | string | `''` | Description of the control. +`format` | string | `'.3g'` | +`layout` | reference to Layout widget | reference to new instance | +`max` | `null` or number (integer) | `null` | +`min` | `null` or number (integer) | `null` | +`style` | reference to DescriptionStyle widget | reference to new instance | Styling customizations +`tabbable` | `null` or boolean | `null` | Is widget tabbable? +`tag_style` | string (one of `'primary'`, `'success'`, `'info'`, `'warning'`, `'danger'`, `''`) | `''` | Use a predefined styling for the tags. +`tooltip` | `null` or string | `null` | A tooltip caption. +`value` | array | `[]` | List of int tags + ### LabelModel (@jupyter-widgets/controls, 2.0.0); LabelView (@jupyter-widgets/controls, 2.0.0) Attribute | Type | Default | Help @@ -979,6 +1047,28 @@ Attribute | Type | Default | Help `titles` | array of string | `[]` | Titles of the pages `tooltip` | `null` or string | `null` | A tooltip caption. + +### TagsInputModel (@jupyter-widgets/controls, 2.0.0); TagsInputView (@jupyter-widgets/controls, 2.0.0) + +Attribute | Type | Default | Help +-----------------|------------------|------------------|---- +`_dom_classes` | array of string | `[]` | CSS classes applied to widget DOM element +`_model_module` | string | `'@jupyter-widgets/controls'` | +`_model_module_version` | string | `'2.0.0'` | +`_model_name` | string | `'TagsInputModel'` | +`_view_module` | string | `'@jupyter-widgets/controls'` | +`_view_module_version` | string | `'2.0.0'` | +`_view_name` | string | `'TagsInputView'` | +`allow_duplicates` | boolean | `true` | +`allowed_tags` | array | `[]` | +`description` | string | `''` | Description of the control. +`layout` | reference to Layout widget | reference to new instance | +`style` | reference to DescriptionStyle widget | reference to new instance | Styling customizations +`tabbable` | `null` or boolean | `null` | Is widget tabbable? +`tag_style` | string (one of `'primary'`, `'success'`, `'info'`, `'warning'`, `'danger'`, `''`) | `''` | Use a predefined styling for the tags. +`tooltip` | `null` or string | `null` | A tooltip caption. +`value` | array | `[]` | List of string tags + ### TextModel (@jupyter-widgets/controls, 2.0.0); TextView (@jupyter-widgets/controls, 2.0.0) Attribute | Type | Default | Help diff --git a/yarn.lock b/yarn.lock index 7e69115f2aa..ec3ced2d83d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1544,6 +1544,11 @@ dependencies: "@types/tern" "*" +"@types/d3-color@^1.2.2": + version "1.2.2" + resolved "https://registry.npmjs.org/@types/d3-color/-/d3-color-1.2.2.tgz#80cf7cfff7401587b8f89307ba36fe4a576bc7cf" + integrity sha512-6pBxzJ8ZP3dYEQ4YjQ+NVbQaOflfgXq/JbDiS99oLobM2o72uAST4q6yPxHv6FOTCRC/n35ktuo8pvw/S4M7sw== + "@types/d3-format@^1.3.1": version "1.3.1" resolved "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.3.1.tgz#35bf88264bd6bcda39251165bb827f67879c4384" @@ -3519,6 +3524,11 @@ cyclist@^1.0.1: resolved "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= +d3-color@^1.4.0: + version "1.4.1" + resolved "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a" + integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q== + d3-format@^1.3.0: version "1.4.3" resolved "https://registry.npmjs.org/d3-format/-/d3-format-1.4.3.tgz#4e8eb4dff3fdcb891a8489ec6e698601c41b96f1"