mirror of
https://github.com/NoRedInk/noredink-ui.git
synced 2024-09-17 10:17:09 +03:00
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:
parent
15adb4bd2a
commit
38ca396ac8
4
.npmignore
Normal file
4
.npmignore
Normal file
@ -0,0 +1,4 @@
|
||||
elm-stuff/
|
||||
src/
|
||||
styleguide-app/
|
||||
tests/
|
7
Makefile
7
Makefile
@ -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
|
||||
|
@ -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
173
lib/CustomElement.js
Normal 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
56
lib/TextArea/V3.js
Normal 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
1
lib/index.js
Normal file
@ -0,0 +1 @@
|
||||
require('./TextArea/V3')
|
1774
package-lock.json
generated
1774
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
1
styleguide-app/.gitignore
vendored
1
styleguide-app/.gitignore
vendored
@ -1 +1,2 @@
|
||||
elm.js
|
||||
javascript.js
|
||||
|
@ -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
|
||||
|
||||
|
||||
{-| -}
|
||||
|
@ -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);
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user