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:
parent
95d78a140f
commit
1f11a79279
@ -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();
|
||||
|
@ -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>
|
||||
|
@ -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' %}
|
||||
|
@ -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>
|
@ -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'):
|
||||
|
@ -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)
|
||||
|
@ -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(
|
||||
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
@ -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";
|
||||
|
@ -0,0 +1,17 @@
|
||||
@-webkit-keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user