diff --git a/admin/javascript/ssui.core.js b/admin/javascript/ssui.core.js
index 758f13c18..105916c58 100644
--- a/admin/javascript/ssui.core.js
+++ b/admin/javascript/ssui.core.js
@@ -1,18 +1,115 @@
(function($) {
/**
- * Allows icon definition via HTML5 data attrs for easier handling in PHP
+ * Allows icon definition via HTML5 data attrs for easier handling in PHP.
+ *
+ * Adds an alternative appearance so we can toggle back and forth between them
+ * and register event handlers to add custom styling and behaviour. Example use
+ * is in the CMS with the saving buttons - depending on the page's state one of
+ * them will either say "Save draft" or "Saved", and will have different colour.
*/
$.widget('ssui.button', $.ui.button, {
+ options: {
+ alternate: {
+ icon: null,
+ text: null
+ },
+ showingAlternate: false
+ },
+
+ /**
+ * Switch between the alternate appearances.
+ */
+ toggleAlternate: function() {
+ if (this._trigger('ontogglealternate')===false) return;
+
+ // Only switch to alternate if it has been enabled through options.
+ if (!this.options.alternate.icon && !this.options.alternate.text) return;
+
+ this.options.showingAlternate = !this.options.showingAlternate;
+ this.refresh();
+ },
+
+ /**
+ * Adjust the appearance to fit with the current settings.
+ */
+ _refreshAlternate: function() {
+ this._trigger('beforerefreshalternate');
+
+ // Only switch to alternate if it has been enabled through options.
+ if (!this.options.alternate.icon && !this.options.alternate.text) return;
+
+ if (this.options.showingAlternate) {
+ this.element.find('.ui-button-icon-primary').hide();
+ this.element.find('.ui-button-text').hide();
+ this.element.find('.ui-button-icon-alternate').show();
+ this.element.find('.ui-button-text-alternate').show();
+ }
+ else {
+ this.element.find('.ui-button-icon-primary').show();
+ this.element.find('.ui-button-text').show();
+ this.element.find('.ui-button-icon-alternate').hide();
+ this.element.find('.ui-button-text-alternate').hide();
+ }
+
+ this._trigger('afterrefreshalternate');
+ },
+
+ /**
+ * Construct button - pulls in options from data attributes.
+ * Injects new elements for alternate appearance (if requested via options).
+ */
_resetButton: function() {
- var iconPrimary = this.element.data('iconPrimary') ? this.element.data('iconPrimary') : this.element.data('icon'),
- iconSecondary = this.element.data('iconSecondary');
+ var iconPrimary = this.element.data('icon-primary'),
+ iconSecondary = this.element.data('icon-secondary');
+
+ if (!iconPrimary) iconPrimary = this.element.data('icon');
+
// TODO Move prefix out of this method, without requriing it for every icon definition in a data attr
if(iconPrimary) this.options.icons.primary = 'btn-icon-' + iconPrimary;
if(iconSecondary) this.options.icons.secondary = 'btn-icon-' + iconSecondary;
$.ui.button.prototype._resetButton.call(this);
- }
+
+ // Pull options from data attributes. Overriden by explicit options given on widget creation.
+ if (!this.options.alternate.text) {
+ this.options.alternate.text = this.element.data('text-alternate');
+ }
+ if (!this.options.alternate.icon) {
+ this.options.alternate.icon = this.element.data('icon-alternate');
+ }
+ if (!this.options.showingAlternate) {
+ this.options.showingAlternate = this.element.hasClass('ss-ui-alternate');
+ }
+
+ // Create missing elements.
+ if (this.options.alternate.text) {
+ this.buttonElement.append(
+ "" + this.options.alternate.text + ""
+ );
+ }
+ if (this.options.alternate.icon) {
+ this.buttonElement.append(
+ ""
+ );
+ }
+
+ this._refreshAlternate();
+ },
+
+ refresh: function() {
+ $.ui.button.prototype.refresh.call(this);
+
+ this._refreshAlternate();
+ },
+
+ destroy: function() {
+ this.element.find('.ui-button-text-alternate').remove();
+ this.element.find('.ui-button-icon-alternate').remove();
+
+ $.ui.button.prototype.destroy.call( this );
+ },
});
/**
diff --git a/docs/en/howto/cms-alternating-button.md b/docs/en/howto/cms-alternating-button.md
new file mode 100644
index 000000000..7db08f80d
--- /dev/null
+++ b/docs/en/howto/cms-alternating-button.md
@@ -0,0 +1,160 @@
+# How to implement an alternating button #
+
+## Introduction ##
+
+*Save* and *Save & publish* buttons alternate their appearance to reflect the state of the underlying `SiteTree` object.
+This is based on a `ssui.button` extension available in `ssui.core.js`.
+
+The button can be configured via the data attributes in the backend, or through jQuery UI initialisation options. The
+state can be toggled from the backend (again through data attributes), and can also be easily toggled or set on the
+frontend.
+
+This how-to will walk you through creation of a "Clean-up" button with two appearances:
+
+* active: "Clean-up now" green constructive button if the actions can be performed
+* netural: "Cleaned" default button if the action does not need to be done
+
+The controller code that goes with this example is listed in [Extend CMS Interface](../reference/extend-cms-interface).
+
+## Backend support ##
+
+First create and configure the action button with alternate state on a page type. The button comes with the default
+state already, so you just need to add the alternate state using two data additional attributes:
+
+* `data-icon-alternate`: icon to be shown when the button is in the alternate state
+* `data-text-alternate`: likewise for text.
+
+Here is the configuration code for the button:
+
+ :::php
+ public function getCMSActions() {
+ $fields = parent::getCMSActions();
+
+ $fields->fieldByName('MajorActions')->push(
+ $cleanupAction = FormAction::create('cleanup', 'Cleaned')
+ // Set up an icon for the neutral state that will use the default text.
+ ->setAttribute('data-icon', 'accept')
+ // Initialise the alternate constructive state.
+ ->setAttribute('data-icon-alternate', 'addpage')
+ ->setAttribute('data-text-alternate', 'Clean-up now')
+ );
+
+ return $fields;
+ }
+
+You can control the state of the button from the backend by applying `ss-ui-alternate` class to the `FormAction`. To
+simplify our example, let's assume the button state is controlled on the backend only, but you'd usually be better off
+adjusting the state in the frontend to give the user the benefit of immediate feedback. This technique might still be
+used for initialisation though.
+
+Here we initialise the button based on the backend check, and assume that the button will only update after page reload
+(or on CMS action).
+
+ :::php
+ public function getCMSActions() {
+ // ...
+ if ($this->needsCleaning()) {
+ // Will initialise the button into alternate state.
+ $cleanupAction->addExtraClass('ss-ui-alternate');
+ }
+ // ...
+ }
+
+## Frontend support ##
+
+As with the *Save* and *Save & publish* buttons, you might want to add some scripted reactions to user actions on the
+frontend. You can affect the state of the button through the jQuery UI calls.
+
+First of all, you can toggle the state of the button - execute this code in the browser's console to see how it works.
+
+ :::js
+ jQuery('.cms-edit-form .Actions #Form_EditForm_action_cleanup').button('toggleAlternate');
+
+Another, more useful, scenario is to check the current state.
+
+ :::js
+ jQuery('.cms-edit-form .Actions #Form_EditForm_action_cleanup').button('option', 'showingAlternate');
+
+You can also force the button into a specific state by using UI options.
+
+ :::js
+ jQuery('.cms-edit-form .Actions #Form_EditForm_action_cleanup').button({showingAlternate: true});
+
+This will allow you to react to user actions in the CMS and give immediate feedback. Here is an example taken from the
+CMS core that tracks the changes to the input fields and reacts by enabling the *Save* and *Save & publish* buttons
+(changetracker will automatically add `changed` class to the form if a modification is detected).
+
+ :::js
+ /**
+ * Enable save buttons upon detecting changes to content.
+ * "changed" class is added by jQuery.changetracker.
+ */
+ $('.cms-edit-form .changed').entwine({
+ // This will execute when the class is added to the element.
+ onmatch: function(e) {
+ var form = this.closest('.cms-edit-form');
+ form.find('#Form_EditForm_action_save').button({showingAlternate: true});
+ form.find('#Form_EditForm_action_publish').button({showingAlternate: true});
+ this._super(e);
+ },
+ // Entwine requires us to define this, even if we don't use it.
+ onunmatch: function(e) {
+ this._super(e);
+ }
+ });
+
+## Frontend hooks ##
+
+`ssui.button` defines several additional events so that you can extend the code with your own behaviours. For example
+this is used in the CMS to style the buttons. Three events are available:
+
+* `ontogglealternate`: invoked when the `toggleAlternate` is called. Return `false` to prevent the toggling.
+* `beforerefreshalternate`: invoked before the alternate-specific rendering takes place, including the button
+initialisation.
+* `afterrefreshalternate`: invoked after the rendering has been done, including on init. Good place to add styling
+extras.
+
+Continuing our example let's add a "constructive" style to our *Clean-up* button. First you need to be able to add
+custom JS code into the CMS. You can do this by adding a new source file, here
+`mysite/javascript/CMSMain.CustomActionsExtension.js`, and requiring it from the config.
+
+ :::ss
+ LeftAndMain::require_javascript('mysite/javascript/CMSMain.CustomActionsExtension.js');
+
+You can now add the styling in response to `afterrefreshalternate` event. Let's use entwine to avoid accidental memory
+leaks. The only complex part here is how the entwine handle is constructed. `onbuttonafterrefreshalternate` can be
+disassembled into:
+
+* `on` signifies the entiwne event handler
+* `button` is jQuery UI widget name
+* `afterrefreshalternate`: the event from ssui.button to react to.
+
+Here is the entire handler put together. You don't need to add any separate initialisation code, this will handle all
+cases.
+
+ :::js
+ (function($) {
+
+ $.entwine('mysite', function($){
+ $('.cms-edit-form .Actions #Form_EditForm_action_cleanup').entwine({
+ /**
+ * onafterrefreshalternate is SS-specific jQuery UI hook that is executed
+ * every time the button is rendered (including on initialisation).
+ */
+ onbuttonafterrefreshalternate: function() {
+ if (this.button('option', 'showingAlternate')) {
+ this.addClass('ss-ui-action-constructive');
+ }
+ else {
+ this.removeClass('ss-ui-action-constructive');
+ }
+ }
+ });
+ });
+
+ }(jQuery));
+
+## Summary ##
+
+The code presented gives you a fully functioning alternating button, similar to the defaults that come with the the CMS.
+These alternating buttons can be used to give user the advantage of visual feedback upon his actions.