Horizon Forms should allow themable number spinners

Much like the themable selects and checkboxes, number spinners
should also be themable.

Standard number spinners are not very customizable.  We should use
existing buttons and fonts to add their functionality to allow a
richer experience if desired downstream.

An example of how to customize the spinner was placed in Material.
The example shows how to use flexbox to change layout type from
column to row, change icon order, and how to override the icons.

'autocomplete' needs to be false on this new element, otherwise
the browser will retain and load the last value without actually
triggering any JavaScript events by which we can key on and update
the state of the spinner buttons.

Change-Id: Ifd266cd515a903841e2d28e2f4731879116e3513
Closes-bug: #1598311
This commit is contained in:
Diana Whitten 2016-07-05 17:46:09 -07:00 committed by Jeremy Moffitt
parent 95d78a140f
commit 1f11a79279
12 changed files with 282 additions and 4 deletions

View File

@ -249,6 +249,14 @@ horizon.forms.init_themable_select = function ($elem) {
// Pass in a container OR the themable select itself
$elem = $elem.hasClass('themable-select') ? $elem : $elem.find('.themable-select');
var initialized_class = 'select-initialized';
if ($elem.hasClass(initialized_class)) {
return;
}
$elem.addClass(initialized_class);
// Update the select value if dropdown value changes
$elem.on('click', 'li a', function () {
var $this = $(this);
@ -338,14 +346,117 @@ horizon.forms.init_themable_select = function ($elem) {
});
};
horizon.forms.init_new_selects = function () {
$(document).on('DOMNodeInserted', function(e) {
var $target = $(e.target);
var newInputs = $target.find('.themable-select').not('.select-initialized');
for (var ii = 0; ii < newInputs.length; ii++) {
horizon.forms.init_themable_select($(newInputs[ii]));
}
});
};
horizon.forms.getSpinnerValue = function(val, defaultVal) {
val = parseInt(val, 10);
return isNaN(val) ? defaultVal : val;
};
horizon.forms.checkSpinnerValue = function($input) {
var val = $input.attr('value');
var max = horizon.forms.getSpinnerValue($input.attr('max'), Number.MAX_SAFE_INTEGER);
var min = horizon.forms.getSpinnerValue($input.attr('min'), 0);
var $parent = $input.parents('.themable-spinner');
var $up = $parent.find('.spinner-up');
var $down = $parent.find('.spinner-down');
$parent.find('.themable-spinner-btn').removeAttr('disabled');
if (val <= min) {
// Disable if we've hit the min
$down.attr('disabled', true);
} else if (val >= max) {
// Disable if we've hit the max
$up.attr('disabled', true);
}
};
horizon.forms.init_themable_spinner = function ($elem) {
"use strict";
// If not specified, find them all
$elem = $elem || $('body');
// If a jQuery object isn't passed in ... make it one
$elem = $elem instanceof jQuery ? $elem : $($elem);
// Pass in a container OR the themable spinner itself
$elem = $elem.hasClass('themable-spinner') ? $elem : $elem.find('.themable-spinner');
// Remove elements already initialized
var initialized_class = 'spinner-initialized';
$elem = $elem.not('.' + initialized_class);
var $input = $elem.find('input[type="number"]');
horizon.forms.checkSpinnerValue($input);
$elem.addClass(initialized_class)
.on('click', '.btn', function() {
var $this = $(this);
var $input = $this.parents('.themable-spinner').find('input');
var max = horizon.forms.getSpinnerValue($input.attr('max'), Number.MAX_SAFE_INTEGER);
var min = horizon.forms.getSpinnerValue($input.attr('min'), 0);
var step = horizon.forms.getSpinnerValue($input.attr('step'), 1);
var originalVal = $input.val();
var val = parseInt(originalVal === '' ? min || 0 : $input.val(), 10);
var new_val = val - step;
if ($this.hasClass('spinner-up')) {
new_val = originalVal ? val + step : min;
// Increase the step if we can
if (max == undefined || new_val <= max) {
$input.val(new_val).trigger('input').trigger('change');
}
} else {
new_val = originalVal ? val - step : min;
// Decrease the step if we can
if (min == undefined || new_val >= min) {
$input.val(new_val).trigger('input').trigger('change');
}
}
});
$input.on('change', function(e) {
horizon.forms.checkSpinnerValue($(e.delegateTarget));
});
};
horizon.forms.init_new_spinners = function () {
$(document).on('DOMNodeInserted', function(e) {
var $target = $(e.target);
var newInputs = $target.find('.themable-spinner').not('.spinner-initialized');
for (var ii = 0; ii < newInputs.length; ii++) {
horizon.forms.init_themable_spinner($(newInputs[ii]));
}
});
};
horizon.addInitFunction(horizon.forms.init = function () {
var $body = $('body');
horizon.forms.handle_submit($body);
horizon.modals.addModalInitFunction(horizon.forms.handle_submit);
horizon.forms.init_themable_select();
horizon.forms.init_new_selects();
horizon.modals.addModalInitFunction(horizon.forms.init_themable_select);
horizon.forms.init_themable_spinner();
horizon.forms.init_new_spinners();
horizon.forms.handle_snapshot_source();
horizon.forms.handle_volume_source();
horizon.forms.handle_image_source();

View File

@ -26,7 +26,11 @@
</span>
</div>
{% else %}
{{ field|add_bootstrap_class }}
{% if field|is_number %}
{% include 'horizon/common/fields/_themable_spinner.html' %}
{% else %}
{{ field|add_bootstrap_class }}
{% endif %}
{% endif %}
{% endwith %}
</div>

View File

@ -10,6 +10,8 @@
{% with is_horizontal=1 %}
{% include 'horizon/common/fields/_themable_checkbox.html' %}
{% endwith %}
{% elif field|is_number %}
{% include 'horizon/common/fields/_themable_spinner.html' %}
{% elif field|is_radio %}
{% with is_horizontal=1 %}
{% include 'horizon/common/fields/_themable_radiobutton.html' %}

View File

@ -0,0 +1,25 @@
{% load form_helpers %}
<div class="input-group themable-spinner">
{{ field|add_bootstrap_class|autocomplete:'off' }}
<div class="input-group-btn-vertical">
<div class="input-group-btn-vertical-container">
<div class="btn-container themable-spinner-inc">
<button
class="btn themable-spinner-btn btn-default btn-xs spinner-up"
type="button"
{% if field.field.max_value and field.field.max_value <= field.field.initial %}disabled{% endif %}>
<span class="fa fa-caret-up hz-spinner-icon-up"></span>
</button>
</div>
<div class="btn-container themable-spinner-dec">
<button
class="btn themable-spinner-btn btn-default btn-xs spinner-down"
type="button"
{% if field.field.min_value and field.field.min_value >= field.field.initial %}disabled{% endif %}>
<span class="fa fa-caret-down hz-spinner-icon-down"></span>
</button>
</div>
</div>
</div>
</div>

View File

@ -36,6 +36,12 @@ def add_bootstrap_class(field):
return field
@register.filter
def autocomplete(field, value='on'):
field.field.widget.attrs['autocomplete'] = value
return field
@register.filter
def is_checkbox(field):
return isinstance(field.field.widget, django.forms.CheckboxInput)
@ -56,6 +62,11 @@ def is_file(field):
return isinstance(field.field.widget, django.forms.FileInput)
@register.filter
def is_number(field):
return isinstance(field.field.widget, django.forms.NumberInput)
@register.filter
def add_item_url(field):
if hasattr(field.field.widget, 'get_add_item_url'):

View File

@ -299,11 +299,11 @@ class CreateProjectWorkflowTests(test.BaseAdminViewTests):
self.assertTemplateUsed(res, views.WorkflowView.template_name)
if django.VERSION >= (1, 10):
pattern = ('<input class="form-control" '
pattern = ('<input autocomplete="off" class="form-control" '
'id="id_subnet" min="-1" '
'name="subnet" type="number" value="10" required/>')
else:
pattern = ('<input class="form-control" '
pattern = ('<input autocomplete="off" class="form-control" '
'id="id_subnet" min="-1" '
'name="subnet" type="number" value="10"/>')
self.assertContains(res, pattern, html=True)

View File

@ -589,7 +589,10 @@ class StackTests(test.TestCase):
'name="__param_param{0}" type="{1}"/>')
self.assertContains(res, input_str.format(1, 'text'), html=True)
self.assertContains(res, input_str.format(2, 'number'), html=True)
# the custom number spinner produces an input element
# that doesn't match the input_strs above
# validate with id alone
self.assertContains(res, 'id="id___param_param2"')
self.assertContains(res, input_str.format(3, 'text'), html=True)
self.assertContains(res, input_str.format(4, 'text'), html=True)
self.assertContains(

View File

@ -0,0 +1,60 @@
.themable-spinner {
display: flex;
// Remove default spinner styles here
// **********************************
input {
-moz-appearance: textfield;
}
& input::-webkit-inner-spin-button,
& input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0; /* Removes leftover margin */
}
// **********************************
input[disabled],
input[readonly] {
& + .input-group-btn-vertical {
.btn {
@include opacity(.65);
@include box-shadow(none);
cursor: $cursor-disabled;
pointer-events: none;
}
}
}
.input-group-btn-vertical {
.btn {
line-height: 1;
margin-left: -1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding-top: 0;
padding-bottom: 0;
height: 100%;
&.spinner-up {
border-bottom-right-radius: 0;
}
&.spinner-down {
border-top-right-radius: 0;
}
&-container {
line-height: 1;
flex: 0 1 50%;
}
}
&-container {
display: flex;
flex-direction: column;
height: 100%;
}
}
}

View File

@ -44,6 +44,7 @@
@import "components/selection_menu";
@import "components/selects";
@import "components/sidebar";
@import "components/spinners";
@import "components/tab";
@import "components/tables";
@import "components/transfer_tables";

View File

@ -0,0 +1,17 @@
@-webkit-keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@ -1,3 +1,4 @@
@import "animations";
@import "icons";
@import "components/checkboxes";
@import "components/context_selection";
@ -14,6 +15,7 @@
@import "components/radiobuttons";
@import "components/selects";
@import "components/sidebar";
@import "components/spinners";
@import "components/trees";
.login .splash-logo {

View File

@ -0,0 +1,42 @@
.themable-spinner {
.input-group-btn-vertical {
.btn {
&,
&:hover,
&:active,
&.active,
&:focus {
box-shadow: none;
background-color: transparent;
border: none;
display: block;
@include animation(fadeIn 1s);
&[disabled] {
display: none;
}
}
}
.hz-spinner-icon-up {
@extend .mdi-plus;
}
.hz-spinner-icon-down {
@extend .mdi-minus;
padding-right: $padding-xs-horizontal;
}
&-container {
flex-direction: row;
}
}
&-inc {
order: 1;
}
&-dec {
order: 0;
}
}