Afform - improve drag-n-drop UI
authorColeman Watts <coleman@civicrm.org>
Wed, 12 May 2021 18:19:03 +0000 (14:19 -0400)
committerColeman Watts <coleman@civicrm.org>
Thu, 13 May 2021 18:24:52 +0000 (14:24 -0400)
Fixes a number of issues with drag-n-drop on the Afform GUI palette & canvas.
Forces panels to take up full vertical space so palette never scrolls offscreen.
Compresses tabs above palette to save space.

ext/afform/admin/ang/afGuiEditor.css
ext/afform/admin/ang/afGuiEditor.js
ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js
ext/afform/admin/ang/afGuiEditor/afGuiEditorCanvas.html
ext/afform/admin/ang/afGuiEditor/afGuiEditorPalette.html
ext/afform/admin/ang/afGuiEditor/afGuiEntity.html
ext/afform/admin/ang/afGuiEditor/afGuiSearch.html
ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.component.js

index 3667005a12235d9d7d862e2cf862c4cbb6a2753a..5025b6e280d67929e305f9c73fe74ad3b83cdbd5 100644 (file)
@@ -1,14 +1,11 @@
 #afGuiEditor #afGuiEditor-palette {
   margin-right: 5px;
+  height: 100%;
 }
 
 #afGuiEditor #afGuiEditor-canvas {
   margin-left: 5px;
-}
-
-#afGuiEditor .panel-body {
-  padding: 5px 12px;
-  position: relative;
+  height: 100%;
 }
 
 #afGuiEditor fieldset legend {
   margin-bottom: 10px;
 }
 
-#afGuiEditor #afGuiEditor-palette-tabs li {
+#afGuiEditor .panel {
+  height: 100%;
+}
+#afGuiEditor .panel-heading {
+  height: 44px;
+  padding: 10px;
+}
+#afGuiEditor .panel-heading ul.nav-tabs {
+  border-bottom: 0 none;
+}
+#afGuiEditor .panel-heading ul.nav-tabs li {
   top: 1px;
 }
-
-#afGuiEditor #afGuiEditor-palette-tabs li > a {
-  padding: 10px 15px;
+#afGuiEditor .panel-heading ul.nav-tabs li.fluid-width-tab {
+  white-space: nowrap;
+  overflow: hidden;
+}
+#afGuiEditor .panel-heading ul.nav-tabs li.active {
+  max-width: 50%;
+}
+#afGuiEditor .panel-heading ul.nav-tabs li > a {
+  padding: 5px 3px 5px 8px;
+  height: 33px;
   font-size: 12px;
+  margin: 0;
+}
+
+#afGuiEditor .panel-body {
+  padding: 5px 12px;
+  position: relative;
+  height: calc(100% - 44px);
+  overflow-y: scroll;
+  overflow-x: hidden;
 }
 
 #afGuiEditor .af-gui-columns {
@@ -49,7 +72,7 @@
 }
 
 #afGuiEditor .crm-editable-enabled,
-#afGuiEditor-palette-tabs > li > a > span {
+#afGuiEditor .panel-heading ul.nav-tabs li > a > span {
   display: inline-block;
   padding: 0 4px !important;
   border: 2px solid transparent !important;
   left: 0;
   padding-left: 15px;
 }
-#afGuiEditor:not(.af-gui-dragging) #afGuiEditor-canvas:hover .af-gui-bar {
+#afGuiEditor:not(.af-gui-dragging *) #afGuiEditor-canvas:hover .af-gui-bar {
   opacity: 1;
   transition: opacity .2s;
 }
   transition: opacity .1s;
 }
 
+/* Disable menu while dragging */
+body.af-gui-dragging #civicrm-menu {
+  pointer-events: none;
+}
+/* Disable scrollbars while dragging */
+body.af-gui-dragging {
+  overflow-x: hidden;
+  overflow-y: hidden;
+}
+
 #afGuiEditor .af-gui-bar .btn.active {
   background-color: #b3b3b3;
 }
   padding: 22px 3px 3px;
   min-height: 40px;
   display: block;
+  margin-bottom: 10px;
+  margin-top: 10px;
 }
 
 #afGuiEditor af-gui-markup,
 
 #afGuiEditor .af-gui-container-type-fieldset {
   box-shadow: 0 0 5px #bbbbbb;
+  margin-top: 20px;
+  margin-bottom: 20px;
 }
 
 #afGuiEditor .af-gui-container:hover,
-#afGuiEditor.af-gui-dragging .af-gui-container {
+.af-gui-dragging #afGuiEditor .af-gui-container {
   border: 2px dashed #757575;
 }
 #afGuiEditor .af-gui-container.af-gui-dragtarget {
   margin-top: 10px;
 }
 
+#afGuiEditor .ui-sortable-helper {
+  height: 20px !important;
+  opacity: .5;
+  overflow: visible;
+}
+#afGuiEditor .ui-sortable-helper > * {
+  background-color: #d5d5d5;
+}
+#afGuiEditor .ui-sortable-helper .af-gui-palette-item {
+  height: 30px;
+  width: 300px;
+  border: 2px dashed #0071bd;
+}
+
 #afGuiEditor .af-gui-entity-palette-select-list {
   max-height: 400px;
   overflow-y: auto;
index 406906a5c1a051e3737e76dfcd218341d6cbf0af..6fde62204abb32cd5beae1a7b7a63236ddbc6fbc 100644 (file)
         $(this).removeClass('af-gui-dragtarget');
       })
       .on('sortstart', '#afGuiEditor', function() {
-        $('#afGuiEditor').addClass('af-gui-dragging');
+        $('body').addClass('af-gui-dragging');
       })
       .on('sortstop', function() {
-        $('.af-gui-dragging').removeClass('af-gui-dragging');
+        $('body').removeClass('af-gui-dragging');
         $('.af-gui-dragtarget').removeClass('af-gui-dragtarget');
       });
   });
index acbf3e23db9788f1d52524bc9db30974fc15dc92..cf5e27e00bc445750f875891954673547c4e9931 100644 (file)
       $scope.saving = false;
       $scope.selectedEntityName = null;
       this.meta = afGui.meta;
-      var editor = this;
+      var editor = this,
+        sortableOptions = {};
 
       this.$onInit = function() {
         // Load the current form plus blocks & fields
         afGui.resetMeta();
         afGui.addMeta(this.data);
         initializeForm();
+
+        $timeout(fixEditorHeight);
+        $timeout(editor.adjustTabWidths);
+        $(window)
+          .on('resize.afGuiEditor', fixEditorHeight)
+          .on('resize.afGuiEditor', editor.adjustTabWidths);
+      };
+
+      this.$onDestroy = function() {
+        $(window).off('.afGuiEditor');
       };
 
       // Initialize the current form
           if (selectTab) {
             editor.selectEntity(type + num);
           }
+          $timeout(editor.adjustTabWidths);
         }
 
         if (meta.fields) {
 
       this.selectEntity = function(entityName) {
         $scope.selectedEntityName = entityName;
+        $timeout(editor.adjustTabWidths);
       };
 
       this.getEntity = function(entityName) {
         return options;
       }
 
+      // Options for ui-sortable in field palette
+      this.getSortableOptions = function(entityName) {
+        if (!sortableOptions[entityName + '']) {
+          sortableOptions[entityName + ''] = {
+            helper: 'clone',
+            appendTo: '#afGuiEditor-canvas-body > af-gui-container',
+            containment: '#afGuiEditor-canvas-body',
+            update: editor.onDrop,
+            items: '> div:not(.disabled)',
+            connectWith: '#afGuiEditor-canvas ' + (entityName ? '[data-entity="' + entityName + '"] > ' : '') + '[ui-sortable]',
+            placeholder: 'af-gui-dropzone',
+            tolerance: 'pointer',
+            scrollSpeed: 8
+          };
+        }
+        return sortableOptions[entityName + ''];
+      };
+
       // Validates that a drag-n-drop action is allowed
       this.onDrop = function(event, ui) {
         var sort = ui.item.sortable;
           });
         }
       });
+
+      // Force editor panels to a fixed height, to avoid palette scrolling offscreen
+      function fixEditorHeight() {
+        var height = $(window).height() - $('#afGuiEditor').offset().top;
+        $('#afGuiEditor').height(Math.floor(height));
+      }
+
+      // Compress tabs on small screens
+      this.adjustTabWidths = function() {
+        $('#afGuiEditor .panel-heading ul.nav-tabs li.active').css('max-width', '');
+        $('#afGuiEditor .panel-heading ul.nav-tabs').each(function() {
+          var remainingSpace = Math.floor($(this).width()) - 1,
+            inactiveTabs = $(this).children('li.fluid-width-tab').not('.active');
+          $(this).children('.active,:not(.fluid-width-tab)').each(function() {
+            remainingSpace -= $(this).width();
+          });
+          if (inactiveTabs.length) {
+            inactiveTabs.css('max-width', Math.floor(remainingSpace / inactiveTabs.length) + 'px');
+          }
+        });
+      };
     }
   });
 
index df975f105891caf5c9bb4c32ace49054151bda85..0b767376da26c05eeeac1400110702b096b99d8d 100644 (file)
     <ul class="nav nav-tabs">
       <li role="presentation" ng-class="{active: canvasTab === 'layout'}">
         <a href ng-click="canvasTab = 'layout'">
+          <i class="crm-i fa-list-alt"></i>
           <span>{{:: ts('Form Layout') }}</span>
         </a>
       </li>
       <li role="presentation" ng-class="{active: canvasTab === 'markup'}">
         <a href ng-click="canvasTab = 'markup'; updateLayoutHtml()">
+          <i class="crm-i fa-code"></i>
           <span>{{:: ts('Markup') }}</span>
         </a>
       </li>
index 02d2b40686389eb49d3aa96be8be2892df3cd90e..4da70867cbbd1a3e6646a2a2f44e63e0c70b41c2 100644 (file)
@@ -1,27 +1,31 @@
 <div id="afGuiEditor-palette-config" class="panel panel-default">
-    <ul id="afGuiEditor-palette-tabs" class="panel-heading nav nav-tabs">
-      <li role="presentation" ng-class="{active: selectedEntityName === null}">
+  <div class="panel-heading">
+    <ul id="afGuiEditor-palette-tabs" class="nav nav-tabs">
+      <li role="presentation" class="fluid-width-tab" ng-class="{active: selectedEntityName === null}" title="{{:: ts('Form Settings') }}">
         <a href ng-click="editor.selectEntity(null)">
+          <i class="crm-i fa-gear"></i>
           <span>{{:: ts('Form Settings') }}</span>
         </a>
       </li>
-      <li role="presentation" ng-repeat="entity in entities" ng-class="{active: selectedEntityName === entity.name}">
+      <li role="presentation" ng-repeat="entity in entities" class="fluid-width-tab" ng-class="{active: selectedEntityName === entity.name}" title="{{ entity.label }}">
         <a href ng-click="editor.selectEntity(entity.name)">
-          <span ng-if="!entity.loading && editor.allowEntityConfig" crm-ui-editable ng-model="entity.label">{{ entity.label }}</span>
-          <span ng-if="!entity.loading && !editor.allowEntityConfig">{{ entity.label }}</span>
+          <i class="crm-i {{:: editor.meta.entities[entity.type].icon }}"></i>
+          <span ng-if="!entity.loading && editor.allowEntityConfig && selectedEntityName === entity.name" crm-ui-editable ng-model="entity.label" ng-change="editor.adjustTabWidths()">{{ entity.label }}</span>
+          <span ng-if="!entity.loading && !(editor.allowEntityConfig && selectedEntityName === entity.name)">{{ entity.label }}</span>
           <i ng-if="entity.loading" class="crm-i fa-spin fa-spinner"></i>
         </a>
       </li>
-      <li role="presentation" ng-repeat="(key, searchDisplay) in editor.meta.searchDisplays" ng-class="{active: selectedEntityName === key}">
+      <li role="presentation" ng-repeat="(key, searchDisplay) in editor.meta.searchDisplays" class="fluid-width-tab" ng-class="{active: selectedEntityName === key}" title="{{ searchDisplay.label }}">
         <a href ng-click="editor.selectEntity(key)">
+          <i class="crm-i {{:: searchDisplay['type:icon'] }}"></i>
           <span>{{ searchDisplay.label }}</span>
         </a>
       </li>
-      <li role="presentation" class="dropdown" ng-if="editor.allowEntityConfig">
-        <a href class="dropdown-toggle" data-toggle="dropdown" title="{{ ts('Add Entity') }}">
-          <span><i class="crm-i fa-plus"></i></span>
+      <li role="presentation" class="dropdown" ng-if="editor.allowEntityConfig" title="{{:: ts('Add Entity') }}">
+        <a href class="dropdown-toggle" data-toggle="dropdown">
+          <i class="crm-i fa-plus"></i>
         </a>
-        <ul class="dropdown-menu">
+        <ul class="dropdown-menu dropdown-menu-right">
           <li ng-repeat="(entityName, entity) in editor.meta.entities" ng-if="entity.defaults">
             <a href ng-click="editor.addEntity(entityName, true)">
               <i class="crm-i {{:: entity.icon }}"></i>
@@ -31,6 +35,7 @@
         </ul>
       </li>
     </ul>
+  </div>
   <div class="panel-body" ng-include="'~/afGuiEditor/config-form.html'" ng-if="selectedEntityName === null"></div>
   <div class="panel-body" ng-repeat="entity in entities" ng-if="selectedEntityName === entity.name">
     <af-gui-entity entity="entity"></af-gui-entity>
index 1c337c553dc0a3c4f1388389b762f5d3774dc3d2..57f26202cfbf09bf5c52e1a9989579178fd9dcc5 100644 (file)
     <div class="af-gui-entity-palette-select-list">
       <div ng-if="elementList.length">
         <label>{{:: ts('Elements') }}</label>
-        <div ui-sortable="{update: buildPaletteLists, items: '&gt; div:not(.disabled)', connectWith: '[ui-sortable]', placeholder: 'af-gui-dropzone'}" ui-sortable-update="$ctrl.editor.onDrop" ng-model="elementList">
+        <div ui-sortable="$ctrl.editor.getSortableOptions()" ui-sortable-update="buildPaletteLists" ng-model="elementList">
           <div ng-repeat="element in elementList" >
-            {{:: elementTitles[$index] }}
+            <div class="af-gui-palette-item">{{:: elementTitles[$index] }}</div>
           </div>
         </div>
       </div>
       <div ng-if="blockList.length">
         <label>{{:: ts('Blocks') }}</label>
-        <div ui-sortable="{update: buildPaletteLists, items: '&gt; div:not(.disabled)', connectWith: '[data-entity=\'' + $ctrl.entity.name + '\'] &gt; [ui-sortable]', placeholder: 'af-gui-dropzone'}" ui-sortable-update="$ctrl.editor.onDrop" ng-model="blockList">
+        <div ui-sortable="$ctrl.editor.getSortableOptions($ctrl.entity.name)" ui-sortable-update="buildPaletteLists" ng-model="blockList">
           <div ng-repeat="block in blockList" ng-class="{disabled: blockInUse(block)}">
-            {{:: blockTitles[$index] }}
+            <div class="af-gui-palette-item">{{:: blockTitles[$index] }}</div>
           </div>
         </div>
       </div>
       <div ng-repeat="fieldGroup in fieldList">
         <div ng-if="fieldGroup.fields.length">
           <label>{{ fieldGroup.label }}</label>
-          <div ui-sortable="{update: buildPaletteLists, items: '&gt; div:not(.disabled)', connectWith: '[data-entity=\'' + fieldGroup.entityName + '\'] &gt; [ui-sortable]', placeholder: 'af-gui-dropzone'}" ui-sortable-update="$ctrl.editor.onDrop" ng-model="fieldGroup.fields">
+          <div ui-sortable="$ctrl.editor.getSortableOptions(fieldGroup.entityName)" ui-sortable-update="buildPaletteLists" ng-model="fieldGroup.fields">
             <div ng-repeat="field in fieldGroup.fields" ng-class="{disabled: fieldInUse(field.name)}">
-              {{:: getField(fieldGroup.entityType, field.name).label }}
+              <div class="af-gui-palette-item">{{:: getField(fieldGroup.entityType, field.name).label }}</div>
             </div>
           </div>
         </div>
index 9115234d9a71099ef9409017e0b84d80c825a553..17a131afcf0354a543b24bd1f3e1881ca5e207c7 100644 (file)
@@ -7,25 +7,25 @@
     <div class="af-gui-entity-palette-select-list">
       <div ng-if="elementList.length">
         <label>{{:: ts('Elements') }}</label>
-        <div ui-sortable="{update: buildPaletteLists, items: '&gt; div:not(.disabled)', connectWith: '[ui-sortable]', placeholder: 'af-gui-dropzone'}" ui-sortable-update="$ctrl.editor.onDrop" ng-model="elementList">
+        <div ui-sortable="$ctrl.editor.getSortableOptions()" ui-sortable-update="buildPaletteLists" ng-model="elementList">
           <div ng-repeat="element in elementList" >
-            {{:: elementTitles[$index] }}
+            <div class="af-gui-palette-item">{{:: elementTitles[$index] }}</div>
           </div>
         </div>
       </div>
       <div ng-if="blockList.length">
         <label>{{:: ts('Blocks') }}</label>
-        <div ui-sortable="{update: buildPaletteLists, items: '&gt; div:not(.disabled)', connectWith: '[ui-sortable]', placeholder: 'af-gui-dropzone'}" ui-sortable-update="$ctrl.editor.onDrop" ng-model="blockList">
+        <div ui-sortable="$ctrl.editor.getSortableOptions()" ui-sortable-update="buildPaletteLists" ng-model="blockList">
           <div ng-repeat="block in blockList" ng-class="{disabled: blockInUse(block)}">
-            {{:: blockTitles[$index] }}
+            <div class="af-gui-palette-item">{{:: blockTitles[$index] }}</div>
           </div>
         </div>
       </div>
       <div ng-if="calcFieldList.length">
         <label>{{:: ts('Calculated Fields') }}</label>
-        <div ui-sortable="{update: buildPaletteLists, items: '&gt; div:not(.disabled)', connectWith: '[ui-sortable]', placeholder: 'af-gui-dropzone'}" ui-sortable-update="$ctrl.editor.onDrop" ng-model="calcFieldList">
+        <div ui-sortable="$ctrl.editor.getSortableOptions()" ui-sortable-update="buildPaletteLists" ng-model="calcFieldList">
           <div ng-repeat="field in calcFieldList" ng-class="{disabled: fieldInUse(field.name)}">
-            {{:: field.defn.label }}
+            <div class="af-gui-palette-item">{{:: field.defn.label }}></div>
           </div>
         </div>
       </div>
index 0b4d6a4d019d2f8a919756141866340fba1d6d0c..3cc79a086d6a2086a0174467a34913473fd974aa 100644 (file)
@@ -43,6 +43,8 @@
         connectWith: '[ui-sortable]',
         cancel: 'input,textarea,button,select,option,a,.dropdown-menu',
         placeholder: 'af-gui-dropzone',
+        tolerance: 'pointer',
+        scrollSpeed: 8,
         containment: '#afGuiEditor-canvas-body'
       };