Updates to TextArea (#70)

* update textarea js code and make it npm-ready

* use an attribute instead so dom debugging is easier

* use data- attribute to be I D I O M A T I C

* version the custom element

* include marica's resize logic improvements

* changes to elm module

* clean up styleguide build and use v3 in styleguide
This commit is contained in:
Luke Westby 2018-06-11 11:08:49 -07:00 committed by GitHub
parent 15adb4bd2a
commit 38ca396ac8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1915 additions and 306 deletions

4
.npmignore Normal file
View File

@ -0,0 +1,4 @@
elm-stuff/
src/
styleguide-app/
tests/

View File

@ -16,12 +16,15 @@ format: node_modules
.PHONY: clean
clean:
rm -rf node_modules styleguide-app/elm.js $(shell find . -type d -name 'elm-stuff')
rm -rf node_modules styleguide-app/elm.js styleguide-app/javascript.js $(shell find . -type d -name 'elm-stuff')
documentation.json: node_modules
elm-make --docs $@
styleguide-app/elm.js: styleguide-app/elm-stuff $(shell find src styleguide-app -type f -name '*.elm')
styleguide-app/javascript.js: lib/index.js
npx browserify --entry lib/index.js --outfile styleguide-app/javascript.js
styleguide-app/elm.js: styleguide-app/javascript.js styleguide-app/elm-stuff $(shell find src styleguide-app -type f -name '*.elm')
cd styleguide-app; elm-make Main.elm --output=$(@F)
# plumbing

View File

@ -18,7 +18,7 @@ We try to avoid breaking changes and the associated major version bumps in this
Suppose you just released version `5.0.1`, a small styling fix in the checkbox widget, for a story you're working on. If the project you're working in currently pulls in `noredink-ui` at version `4.x`, then getting to your styling fix means pulling in a new major version of `noredink-ui`. This breaks all `TextArea` widgets across the project, so those will need to be fixed before you can do anything else, potentially a big effort.
To prevent these big Yaks from suddenly showing up in seemingly trivial tasks we prefer to avoid breaking changes in the package. Instead when we need to make a breaking change in a widget, we create a new module for it `Nri.Ui.MyWidget.VX`.
To prevent these big Yaks from suddenly showing up in seemingly trivial tasks we prefer to avoid breaking changes in the package. Instead when we need to make a breaking change in a widget, we create a new module for it `Nri.Ui.MyWidget.VX`. Similarly, when we build custom elements in JavaScript we create a file `lib/MyWidget/VX.js` and define a custom element `nri-mywidget-vX`.
We should change this process if we feel it's not working for us!

173
lib/CustomElement.js Normal file
View File

@ -0,0 +1,173 @@
/**
* @module CustomElement
*/
// Return a function for making an event based on what the browser supports.
// IE11 doesn't support Event constructor, and uses the old Java-style
// methods instead
function makeMakeEvent() {
try {
// if calling Event with new works, do it that way
var testEvent = new CustomEvent('myEvent', { detail: 1 })
return function makeEventNewStyle(type, detail) {
return new Event(type, { detail: detail })
}
} catch (_error) {
// if calling CustomEvent with new throws an error, do it the old way
return function makeEventOldStyle(type, detail) {
var event = document.createEvent('CustomEvent')
event.initCustomEvent(type, false, false, detail)
return event
}
}
}
/**
* Create a DOM Event without worrying about browser compatibility
*
* @param {string} eventName The name of the event to create
* @param {*} [detail] The optional details to attach to the event
* @return {Event} A valid Event instance that can be dispatched on a DOM node
* @example
* var event = CustomElement.makeEvent('change', { name: this._secretInput.value })
* this.dispatchEvent(event)
*/
exports.makeEvent = makeMakeEvent()
function noOp() {}
/**
* Register a custom element using some straightforward and convenient configuration
*
* @param {Object} config
* @param {string} config.tagName The name of the tag for which to create a custom element. Must contain at least one `-` and must not have already been defined.
* @param {Object<string, Object>} config.properties Getters and setters for properties on the custom element that can be accessed and set with `Html.Attributes.property`. These should map a property name onto a `get` function that returns a value and a `set` function that applies a value from the only argument.
* @param {Object<string, function>} config.methods Functions that can be invoked by the custom element from anywhere. These methods are automatically bound to `this`.
* @param {function} config.initialize Method invoked during setup that can be used to initialize internal state. This function is called whenever a new instance of the element is created.
* @param {function} config.onConnect Method invoked whenever the element is inserted into the DOM.
* @param {function} config.onDisconnect Method invoked whenever the element is removed from the DOM.
* @param {function} config.onAttributeChange Method invoked whenever an observed attribute changes. Takes 3 arguments: the name of the attribute, the old value, and the new value.
* @param {string[]} config.observedAttributes List of attributes that are watched such that onAttributeChange will be invoked when they change.
* @example
* CustomElements.create({
* // This is where you specify the tag for your custom element. You would
* // use this custom element with `Html.node "my-custom-tag"`.
* tagName: 'my-cool-button',
*
* // Initialize any local variables for the element or do whatever else you
* // might do in a class constructor. Takes no arguments.
* // NOTE: the element is NOT in the DOM at this point.
* initialize: function() {
* this._hello = 'world'
* this._button = document.createElement('button')
* },
*
* // Do any setup work after the element has been inserted into the DOM.
* // Takes no arguments. This is a proxy for `connectedCallback` (see:
* // https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks)
* onConnect: function() {
* document.addEventListener('click', this._onDocClick)
* this._button.addEventListener('click', this._onButtonClick)
* this.appendChild(this._button)
* },
*
* // Do any teardown work after the element has been removed from the DOM.
* // Takes no arguments. This is a proxy for `disconnectedCallback` (see:
* // https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks)
* onDisconnect: function() {
* document.removeEventListener('click', this._onDocClick)
* },
*
* // Let the custom element runtime know that you want to be notified of
* // changes to the `hello` attribute
* observedAttributes: ['hello'],
*
* // Do any updating when an attribute changes on the element. Note the
* // difference between attributes and properties of an element (see:
* // https://javascript.info/dom-attributes-and-properties). This is a
* // proxy for `attributeChangedCallback` (see:
* // https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks).
* // Takes the name of the attribute that changed, the previous string value,
* // and the new string value.
* onAttributeChange: function(name, previous, next) {
* if (name === 'hello') this._hello = next
* },
*
* // Set up properties. These allow you to expose data to Elm's virtual DOM.
* // You can use any value that can be encoded as a `Json.Encode.Value`.
* // You'll often want to implement updates to some visual detail of your element
* // from within the setter of a property that controls it. Handlers will be
* // automatically bound to the correct value of `@`, so you don't need to worry
* // about method context.
* properties: {
* hello: {
* get: function() {
* return this._hello
* },
* set: function(value) {
* this._hello = value
* this._button.textContent = value
* }
* }
* },
*
* // Set up methods that you can call from anywhere else in the configuration.
* // Methods will be automatically bound to the correct value of `@`, so you
* // don't need to worry about method context.
* methods: {
* _onDocClick: function() {
* alert('document clicked')
* },
* _onButtonClick: function() {
* alert("clicked on #{@_hello} button")
* }
* }
* })
*/
exports.create = function create(config) {
if (customElements.get(config.tagName)) {
throw Error('Custom element with tag name ' + config.tagName + ' already exists.')
}
config.methods = config.methods || {}
config.properties = config.properties || {}
function CustomElementConstructor() {
// This is the best we can do to trick modern browsers into thinking this
// is a real, legitimate class constructor and not a plane old JS function.
var _this = HTMLElement.call(this) || this
if (typeof config.initialize === 'function') {
config.initialize.call(_this)
}
for (var key in config.methods) {
if (!config.methods.hasOwnProperty(key)) continue
var method = config.methods[key]
if (typeof method !== 'function') continue
_this[key] = method.bind(_this)
}
Object.defineProperties(_this, config.properties)
return _this
}
// Some browsers respect this in various debugging tools.
CustomElementConstructor.displayName = '<' + config.tagName + '> custom element'
CustomElementConstructor.prototype = Object.create(HTMLElement.prototype)
CustomElementConstructor.prototype.constructor = CustomElementConstructor
CustomElementConstructor.prototype.connectedCallback = config.onConnect
CustomElementConstructor.prototype.disconnectedCallback = config.onDisconnect
CustomElementConstructor.prototype.attributeChangedCallback = config.onAttributeChange
if (config.observedAttributes) {
var observedAttributes = config.observedAttributes
Object.defineProperty(CustomElementConstructor, 'observedAttributes', {
get: function() { return observedAttributes }
})
}
customElements.define(config.tagName, CustomElementConstructor)
}

56
lib/TextArea/V3.js Normal file
View File

@ -0,0 +1,56 @@
CustomElement = require('../CustomElement')
CustomElement.create({
tagName: 'nri-textarea-v3',
initialize: function() {
this._autoresize = false
},
onConnect: function() {
this._textarea = this.querySelector('textarea')
this._updateListener()
},
observedAttributes: ['data-autoresize'],
onAttributeChange: function(name, previous, next) {
if (name === 'data-autoresize') {
this._autoresize = next !== null
this._updateListener()
}
},
methods: {
_updateListener: function() {
if (this._autoresize) {
this._textarea.addEventListener('input', this._resize)
this._resize()
} else {
this._textarea.removeEventListener('input', this._resize)
}
},
_resize: function() {
var minHeight = null
if (this._textarea.style.minHeight) {
minHeight = parseInt(this._textarea.style.minHeight, 10)
} else {
minHeight = parseInt(window.getComputedStyle(this._textarea).minHeight, 10)
}
if (minHeight === 0) {
minHeight = parseInt(window.getComputedStyle(this._textarea).height, 10)
}
this._textarea.style.overflowY = 'hidden'
this._textarea.style.minHeight = minHeight + 'px'
this._textarea.style.transition = 'none'
if (this._textarea.scrollHeight > minHeight) {
this._textarea.style.height = minHeight + 'px'
this._textarea.style.height = this._textarea.scrollHeight + 'px'
} else {
this._textarea.style.height = minHeight + 'px'
}
}
}
})

1
lib/index.js Normal file
View File

@ -0,0 +1 @@
require('./TextArea/V3')

1774
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
{
"name": "noredink-ui",
"name": "@noredink/ui",
"version": "1.0.0",
"description": "UI widgets we use.",
"main": "index.js",
"main": "lib/index.js",
"directories": {
"test": "tests"
},
@ -19,9 +19,10 @@
"url": "https://github.com/NoRedInk/NoRedInk-ui/issues"
},
"homepage": "https://github.com/NoRedInk/NoRedInk-ui#readme",
"dependencies": {
"elm": "^0.18.0",
"elm-format": "^0.7.0-exp",
"elm-test": "^0.18.12"
"devDependencies": {
"browserify": "16.2.2",
"elm": "0.18.0",
"elm-format": "0.7.0-exp",
"elm-test": "0.18.12"
}
}

View File

@ -88,61 +88,38 @@ contentCreation model =
view_ : Theme -> Model msg -> Html msg
view_ theme model =
let
minHeight =
autoresizeAttrs =
case model.height of
AutoResize _ ->
[ Attributes.attribute "data-autoresize" "" ]
Fixed ->
[]
AutoResize minimumHeight ->
[ calculateMinHeight theme minimumHeight
|> Css.minHeight
]
sharedAttributes =
[ Events.onInput model.onInput
, Attributes.id (generateId model.label)
, Attributes.css
[ InputStyles.input theme model.isInError
]
, Attributes.autofocus model.autofocus
, Attributes.placeholder model.placeholder
, Attributes.attribute "data-gramm" "false" -- disables grammarly to prevent https://github.com/NoRedInk/NoRedInk/issues/14859
, Attributes.css
(minHeight
++ [ Css.boxSizing Css.borderBox ]
)
]
in
Html.div
[ Attributes.css [ Css.position Css.relative ]
]
[ case model.height of
AutoResize _ ->
{- NOTES:
The autoresize-textarea element is implemented to pass information applied to itself to an internal
textarea element that it inserts into the DOM automatically. Maintaing this behavior may require some
changes on your part, as listed below.
Html.styled Html.div
[ Css.position Css.relative ]
[]
[ Html.styled (Html.node "nri-textarea-v3")
[ Css.display Css.block ]
autoresizeAttrs
[ Html.styled Html.textarea
[ InputStyles.input theme model.isInError
, Css.boxSizing Css.borderBox
, case model.height of
AutoResize minimumHeight ->
Css.minHeight (calculateMinHeight theme minimumHeight)
- When adding an Html.Attribute that is a _property_, you must edit Nri/TextArea.js to ensure that a getter and setter
are set up to properly reflect the property to the actual textarea element that autoresize-textarea creates
- When adding a new listener from Html.Events, you must edit Nri/TextArea.js to ensure that a listener is set up on
the textarea that will trigger this event on the autoresize-textarea element itself. See AutoresizeTextArea.prototype._onInput
and AutoresizeTextArea.prototype.connectedCallback for an example pertaining to the `input` event
- When adding a new Html.Attribute that is an _attribute_, you don't have to do anything. All attributes are
automatically reflected onto the textarea element via AutoresizeTextArea.prototype.attributeChangedCallback
-}
Html.node "autoresize-textarea"
(sharedAttributes
++ [ -- setting the default value via a text node doesn't play well with the custom element,
-- but we'll be able to switch to the regular value property in 0.19 anyway
Attributes.defaultValue model.value
]
)
[]
Fixed ->
Html.textarea sharedAttributes
[ Html.text model.value ]
Fixed ->
Css.batch []
]
[ Events.onInput model.onInput
, Attributes.id (generateId model.label)
, Attributes.autofocus model.autofocus
, Attributes.placeholder model.placeholder
, Attributes.attribute "data-gramm" "false" -- disables grammarly to prevent https://github.com/NoRedInk/NoRedInk/issues/14859
]
[ Html.text model.value ]
]
, if not model.showLabel then
Html.label
[ Attributes.for (generateId model.label)

View File

@ -1 +1,2 @@
elm.js
javascript.js

View File

@ -6,14 +6,13 @@ module Examples.TextArea exposing (Msg, State, example, init, update)
-}
import Css
import Dict exposing (Dict)
import Html
import Html.Styled
import ModuleExample as ModuleExample exposing (Category(..), ModuleExample)
import Nri.Ui.Checkbox.V2 as Checkbox
import Nri.Ui.Text.V2 as Text
import Nri.Ui.TextArea.V2 as TextArea
import Nri.Ui.TextArea.V3 as TextArea
{-| -}

View File

@ -1,108 +0,0 @@
function AutoresizeTextArea() {
var _this = HTMLElement.call(this) || this;
_this._onInput = _this._onInput.bind(_this);
_this._textarea = document.createElement("textarea");
return _this;
}
AutoresizeTextArea.prototype = Object.create(HTMLElement.prototype);
AutoresizeTextArea.prototype.constructor = AutoresizeTextArea;
Object.defineProperties(AutoresizeTextArea.prototype, {
defaultValue: {
get: function() {
return this._textarea.defaultValue;
},
set: function(value) {
this._textarea.defaultValue = value;
}
},
autofocus: {
get: function() {
return this._textarea.autofocus;
},
set: function(value) {
this._textarea.autofocus = value;
}
},
placeholder: {
get: function() {
return this._textarea.placeholder;
},
set: function(value) {
this._textarea.placeholder = value;
}
},
value: {
get: function() {
return this._textarea.value;
},
set: function(value) {
this._textarea.value = value;
}
}
});
AutoresizeTextArea.prototype._resize = function() {
var minHeight = null;
if (this._textarea.style.minHeight) {
minHeight = parseInt(this._textarea.style.minHeight, 10);
} else {
minHeight = parseInt(window.getComputedStyle(this._textarea).minHeight, 10);
}
if (minHeight === 0) {
minHeight = parseInt(window.getComputedStyle(this._textarea).height, 10);
}
this._textarea.style.overflowY = "hidden";
this._textarea.style.minHeight = minHeight + "px";
this._textarea.style.transition = "none";
if (this._textarea.scrollHeight > minHeight) {
this._textarea.style.height = minHeight + "px";
this._textarea.style.height = this._textarea.scrollHeight + "px";
} else {
this._textarea.style.height = minHeight + "px";
}
};
AutoresizeTextArea.prototype._onInput = function() {
this._resize();
this.dispatchEvent(new Event("input"));
};
AutoresizeTextArea.prototype._reflectAttributes = function() {
while (this.attributes.length) {
var attribute = this.attributes[0];
this.removeAttributeNode(attribute);
this._textarea.setAttributeNode(attribute);
}
for (var k in this.dataset) {
this._textarea.dataset[k] = this.dataset[k];
delete this.dataset[k];
}
};
AutoresizeTextArea.prototype.attributeChangedCallback = function(
attribute,
previous,
next
) {
if (previous && !next) {
this._textarea.removeAttribute(attribute);
} else {
this._textarea.setAttribute(attribute, next);
}
};
AutoresizeTextArea.prototype.connectedCallback = function() {
this._textarea.addEventListener("input", this._onInput);
this._reflectAttributes();
this.appendChild(this._textarea);
this._resize();
};
AutoresizeTextArea.prototype.disconnectedCallback = function() {
this._textarea.removeEventListener("input", this._onInput);
};
customElements.define("autoresize-textarea", AutoresizeTextArea);

View File

@ -6,8 +6,8 @@
<link href="https://fonts.googleapis.com/css?family=Muli:400,400i,600,600i,700,700i,800,800i,900,900i" rel="stylesheet">
<script src="assets/custom-elements/custom-elements.min.js"></script>
<script src="assets/custom-elements/native-shim.js"></script>
<script src="assets/TextArea.js"></script>
<script src="assets/generated_svgs.js"></script>
<script src="javascript.js"></script>
</head>
<body>
<script src="elm.js"></script>