Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow Button to receive configuration via JavaScript #2867

Merged
merged 7 commits into from
Sep 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/govuk/all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import Tabs from './components/tabs/tabs.mjs'
* @param {Object} [config]
* @param {HTMLElement} [config.scope=document] - scope to query for components
* @param {Object} [config.accordion] - accordion config
* @param {Object} [config.button] - button config
* @param {Object} [config.notificationBanner] - notification banner config
*/
function initAll (config) {
Expand All @@ -36,7 +37,7 @@ function initAll (config) {

var $buttons = $scope.querySelectorAll('[data-module="govuk-button"]')
nodeListForEach($buttons, function ($button) {
new Button($button).init()
new Button($button, config.button).init()
})

var $characterCounts = $scope.querySelectorAll('[data-module="govuk-character-count"]')
Expand Down
34 changes: 29 additions & 5 deletions src/govuk/components/button/button.mjs
Original file line number Diff line number Diff line change
@@ -1,20 +1,46 @@
import { mergeConfigs, normaliseDataset } from '../../common.mjs'
import '../../vendor/polyfills/Event.mjs' // addEventListener and event.target normaliziation
import '../../vendor/polyfills/Function/prototype/bind.mjs'

var KEY_SPACE = 32
var DEBOUNCE_TIMEOUT_IN_SECONDS = 1

function Button ($module) {
/**
* JavaScript enhancements for the Button component
*
* @class
* @param {HTMLElement} $module - The element this component controls
* @param {Object} config
* @param {Boolean} [config.preventDoubleClick=false] - Whether the button should prevent double clicks
*/
function Button ($module, config) {
if (!$module) {
return this
}

this.$module = $module
this.debounceFormSubmitTimer = null

var defaultConfig = {
preventDoubleClick: false
}
this.config = mergeConfigs(
defaultConfig,
config || {},
normaliseDataset($module.dataset)
)
}

/**
* Initialise component
*/
Button.prototype.init = function () {
if (!this.$module) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asking for clarification:

At the start of the module we're checking for the existence of $module, and only if it exists does the code to assign $module to this.$module run.

Here we are then checking for the existence of this.$module, however wouldn't the previous steps ensure that this statement would never evaluate to false?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately we need that extra check in init because the first check only shortcuts the constructor, to safeguard the access ot the module's dataset. The addEventListener calls happen inside init() fail if the component was instantiated with an undefined/null element.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the constructor was called with an undefined element, wouldn't we want to immediately halt execution anyway?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there's anything we can do from the constructor to stop the init function from being called – that's happening outside of the object (new govukButton($module).init()).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean letting the JS throw an Error when trying to access $module.dataset in the constructor?

We had to set a safety for that in the ErrorSummary to avoid making a breaking change for users that may have a mis-initialised ErrorSummary component. For ex. with a blanket new ErrorSummary(document.querySelector('[data-module="error-summary"]')) that runs on a page without an error summary. With the current version, their code wouldn't break, so we wanted to keep that happening after we added the configuration. I'd be quite keen to remove that safety in the next breaking version as it'll be more informative to users to have an error in their console.

For consistency, I've set that same mechanism on the Button, but need the checks in the two places because of the two step initialisation of components (instantiation + init()).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry Ollie, I was commenting on an old version of the page and hadn't seen you'd replied.

I don't think there's anything we can do from the constructor to stop the init function from being called – that's happening outside of the object (new govukButton($module).init()).

We could always make init() a no-op in the constructor. That would keep everything in the same place. That'd actually make the thing quite portable from component to component.

if (!$module) {
   // Make `init` a no-op to avoid potentially breaking code at initialisation
   // from lack of HTML element 
   this.init = function(){}
   return this
}

Copy link
Member Author

@romaricpascal romaricpascal Sep 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll actually update the PR with that approach as:

  • it'll avoid the awkward second check that prompted @querkmachine's question
  • it'll be easy to pass from one component to another

I'll open a second PR to amend the components we've already made configurable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not 100% sure about this from a code readability perspective – appreciate at the minute the init function is near the constructor but I think it'd be easy to miss and I don't think I'd expect a class method to be redefined in this way…

If I was writing this I think I'd stick with the two guard clauses we had before, especially if we plan to get rid of the init function in the short-to-mid term.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to reset to the previous commit, it's not a solution I was particularly attached to and it'll become irrelevant when we remove the init function. Just thought it'd make sense to keep everything in a single place instead of the double check, especially as it's for handling an "error" scenario.

return
}

this.$module.addEventListener('keydown', this.handleKeyDown)
this.$module.addEventListener('click', this.debounce)
this.$module.addEventListener('click', this.debounce.bind(this))
}

/**
Expand Down Expand Up @@ -46,10 +72,8 @@ Button.prototype.handleKeyDown = function (event) {
* @param {MouseEvent} event
*/
Button.prototype.debounce = function (event) {
var target = event.target

// Check the button that was clicked has preventDoubleClick enabled
if (target.getAttribute('data-prevent-double-click') !== 'true') {
if (!this.config.preventDoubleClick) {
return
}

Expand Down
Loading