Horizon Checkboxes are now themeable.

Horizon checkboxes were using a standard checkbox. Unfortunately,
this type of checkbox is only customizable through Chrome, and
even then, its not completely flexible.

The default checkboxes have now been altered to allow for a highly
customized experience through the use of CSS pseudo elements and
Icon Fonts. This allows the color, size and unselected and selected
states of the checkbox to be customized.

The 'default' theme uses the standard Font Awesome checked and
unchecked icons.  The 'material' now uses the Material Design
checkbox design.

It was also noticed (and fixed) that the help-icon on the forms
were not the same color as its corresponding text.

Partially-Implements: blueprint horizon-theme-css-reorg

Change-Id: I52602357d831a5e978fe6916b37b0cde9edb2b9b
This commit is contained in:
Diana Whitten 2015-12-01 18:10:26 -07:00
parent dcc838128e
commit 259973dd06
24 changed files with 288 additions and 87 deletions

View File

@ -209,6 +209,7 @@ full use of the Bootstrap theme architecture.
* Login_
* Tabs_
* Alerts_
* Checkboxes_
Step 1
------
@ -321,6 +322,13 @@ Alerts
Alerts use the basic Bootstrap brand colors. See **Colors** section of your
variables file for specifics.
Checkboxes
----------
Horizon uses icon fonts to represent checkboxes. In order to customize
this, you simply need to override the standard scss. For an example of
this, see themes/material/static/horizon/components/_checkboxes.scss
Bootswatch and Material Design
------------------------------

View File

@ -33,6 +33,8 @@ from horizon.forms.fields import IPv4 # noqa
from horizon.forms.fields import IPv6 # noqa
from horizon.forms.fields import MultiIPField # noqa
from horizon.forms.fields import SelectWidget # noqa
from horizon.forms.fields import ThemableCheckboxInput # noqa
from horizon.forms.fields import ThemableCheckboxSelectMultiple # noqa
from horizon.forms.views import ModalFormMixin # noqa
from horizon.forms.views import ModalFormView # noqa
@ -45,6 +47,8 @@ __all__ = [
"ModalFormMixin",
"DynamicTypedChoiceField",
"DynamicChoiceField",
"ThemableCheckboxInput",
"ThemableCheckboxSelectMultiple",
"IPField",
"IPv4",
"IPv6",

View File

@ -16,6 +16,7 @@ import re
import netaddr
import six
import uuid
from django.core.exceptions import ValidationError # noqa
from django.core import urlresolvers
@ -253,3 +254,44 @@ class DynamicChoiceField(fields.ChoiceField):
class DynamicTypedChoiceField(DynamicChoiceField, fields.TypedChoiceField):
"""Simple mix of ``DynamicChoiceField`` and ``TypedChoiceField``."""
pass
class ThemableCheckboxInput(widgets.CheckboxInput):
"""A subclass of the ``Checkbox`` widget which renders extra markup to
allow a custom checkbox experience.
"""
def render(self, name, value, attrs=None):
label_for = attrs.get('id', '')
if not label_for:
attrs['id'] = uuid.uuid4()
label_for = attrs['id']
return html.format_html(
u'<div class="themable-checkbox">{}<label for="{}"></label></div>',
super(ThemableCheckboxInput, self).render(name, value, attrs),
label_for
)
class ThemableCheckboxChoiceInput(widgets.CheckboxChoiceInput):
def render(self, name=None, value=None, attrs=None, choices=()):
if self.id_for_label:
label_for = html.format_html(' for="{}"', self.id_for_label)
else:
label_for = ''
attrs = dict(self.attrs, **attrs) if attrs else self.attrs
return html.format_html(
u'<div class="themable-checkbox">{}<label{}>' +
u'<span>{}</span></label></div>',
self.tag(attrs), label_for, self.choice_label
)
class ThemableCheckboxFieldRenderer(widgets.CheckboxFieldRenderer):
choice_input_class = ThemableCheckboxChoiceInput
class ThemableCheckboxSelectMultiple(widgets.CheckboxSelectMultiple):
renderer = ThemableCheckboxFieldRenderer
_empty_value = []

View File

@ -89,9 +89,12 @@ horizon.datatables = {
// Only replace row if the html content has changed
if($new_row.html() !== $row.html()) {
if($row.find('.table-row-multi-select:checkbox').is(':checked')) {
// Directly accessing the checked property of the element
// is MUCH faster than using jQuery's helper method
if($row.find('.table-row-multi-select')[0].checked) {
// Preserve the checkbox if it's already clicked
$new_row.find('.table-row-multi-select:checkbox').prop('checked', true);
$new_row.find('.table-row-multi-select').prop('checked', true);
}
$row.replaceWith($new_row);
// Reset tablesorter's data cache.
@ -151,37 +154,50 @@ horizon.datatables = {
});
},
validate_button: function ($form) {
validate_button: function ($form, disable_button) {
// Enable or disable table batch action buttons based on row selection.
$form = $form || $(".table_wrapper > form");
$form.each(function () {
var $this = $(this);
var checkboxes = $this.find(".table-row-multi-select:checkbox");
var action_buttons = $this.find('.table_actions button[data-batch-action="true"]');
action_buttons.toggleClass("disabled", !checkboxes.filter(":checked").length);
var $action_buttons = $this.find('.table_actions button[data-batch-action="true"]');
if (typeof disable_button == undefined) {
disable_button = $this.find(".table-row-multi-select").filter(":checked").length > 0;
}
$action_buttons.toggleClass("disabled", disable_button);
});
},
initialize_checkboxes_behavior: function() {
// Bind the "select all" checkbox action.
$('div.table_wrapper, #modal_wrapper').on('click', 'table thead .multi_select_column .table-row-multi-select:checkbox', function() {
var $this = $(this),
$table = $this.closest('table'),
is_checked = $this.prop('checked'),
checkboxes = $table.find('tbody .table-row-multi-select:visible:checkbox');
checkboxes.prop('checked', is_checked);
});
// Change "select all" checkbox behavior while any checkbox is checked/unchecked.
$("div.table_wrapper, #modal_wrapper").on("click", 'table tbody .table-row-multi-select:checkbox', function () {
var $table = $(this).closest('table');
var $multi_select_checkbox = $table.find('thead .multi_select_column .table-row-multi-select:checkbox');
var any_unchecked = $table.find("tbody .table-row-multi-select:checkbox").not(":checked");
$multi_select_checkbox.prop('checked', any_unchecked.length === 0);
});
// Enable/disable table batch action buttons when row selection changes.
$("div.table_wrapper, #modal_wrapper").on("click", '.table-row-multi-select:checkbox', function () {
horizon.datatables.validate_button($(this).closest("form"));
});
$('.table_wrapper, #modal_wrapper')
.on('change', '.table-row-multi-select', function() {
var $this = $(this);
var $table = $this.closest('table');
var is_checked = $this.prop('checked');
if ($this.hasClass('multi-select-header')) {
// Only select / deselect the visible rows
$table.find('tbody tr:visible .table-row-multi-select')
.prop('checked', is_checked);
} else {
// Find the master checkbox
var $multi_select_checkbox = $table.find('.multi-select-header');
// Determine if there are any unchecked checkboxes in the table
var $checkboxes = $table.find('tbody .table-row-multi-select');
var not_checked = $checkboxes.not(':checked').length;
is_checked = $checkboxes.length != not_checked;
// If there are none, then check the master checkbox
$multi_select_checkbox.prop('checked', not_checked == 0);
}
// Pass in whether it should be visible, no point in doing this twice
horizon.datatables.validate_button($this.closest('form'), !is_checked);
});
},
initialize_table_tooltips: function() {
@ -229,7 +245,7 @@ horizon.datatables.confirm = function (action) {
var actions_div = $(action).closest("div");
if(actions_div.hasClass("table_actions") || actions_div.hasClass("table_actions_menu")) {
// One or more checkboxes selected
$("#"+closest_table_id+" tr[data-display]").has(".table-row-multi-select:checkbox:checked").each(function() {
$("#"+closest_table_id+" tr[data-display]").has(".table-row-multi-select:checked").each(function() {
name_array.push(" \"" + $(this).attr("data-display") + "\"");
});
name_array.join(", ");
@ -480,11 +496,30 @@ horizon.datatables.set_table_sorting = function (parent) {
});
};
horizon.datatables.add_table_checkboxes = function(parent) {
$(parent).find('table thead .multi_select_column').each(function(index, thead) {
if (!$(thead).find('.table-row-multi-select:checkbox').length &&
$(thead).parents('table').find('tbody .table-row-multi-select:checkbox').length) {
$(thead).append('<input type="checkbox" class="table-row-multi-select">');
horizon.datatables.add_table_checkboxes = function($parent) {
$($parent).find('table thead .multi_select_column').each(function() {
var $thead = $(this);
if (!$thead.find('.table-row-multi-select').length &&
$thead.parents('table').find('tbody .table-row-multi-select').length) {
// Build up the themable checkbox
var $container = $(document.createElement('div'))
.addClass('themable-checkbox');
// Create the input checkbox
var $input = $(document.createElement('input'))
.attr('type', 'checkbox')
.addClass('table-row-multi-select multi-select-header')
.uniqueId()
.appendTo($container);
// Create the label
$(document.createElement('label'))
.attr('for', $input.attr('id'))
.appendTo($container);
// Append to the thead last, for speed
$thead.append($container);
}
});
};
@ -576,10 +611,11 @@ horizon.addInitFunction(horizon.datatables.init = function() {
horizon.datatables.initialize_table_tooltips();
// Trigger run-once setup scripts for tables.
horizon.datatables.add_table_checkboxes($('body'));
horizon.datatables.set_table_sorting($('body'));
horizon.datatables.set_table_query_filter($('body'));
horizon.datatables.set_table_fixed_filter($('body'));
var $body = $('body');
horizon.datatables.add_table_checkboxes($body);
horizon.datatables.set_table_sorting($body);
horizon.datatables.set_table_query_filter($body);
horizon.datatables.set_table_fixed_filter($body);
horizon.datatables.disable_actions_on_submit();
// Also apply on tables in modal views.

View File

@ -37,6 +37,7 @@ import six
from horizon import conf
from horizon import exceptions
from horizon.forms import ThemableCheckboxInput
from horizon import messages
from horizon.tables.actions import FilterAction # noqa
from horizon.tables.actions import LinkAction # noqa
@ -672,7 +673,7 @@ class Cell(html.HTMLElement):
if column.auto == "multi_select":
data = ""
if row.can_be_selected(datum):
widget = forms.CheckboxInput(check_test=lambda value: False)
widget = ThemableCheckboxInput(check_test=lambda value: False)
# Convert value to string to avoid accidental type conversion
data = widget.render('object_ids',
six.text_type(table.get_object_id(datum)),

View File

@ -3,27 +3,7 @@
<div class="form-group{% if field.errors %} has-error{% endif %} {{ field.css_classes }}">
{% if field|is_checkbox %}
<div class="{{ classes.single_value }}">
<div class="checkbox">
{% if field.auto_id %}
<label
{% if field.field.required and form.required_css_class %}
class="{{ form.required_css_class }}"
{% endif %}>
{{ field }}
<span>{{ field.label }}</span>
{% if field.field.required %}{% include "horizon/common/_form_field_required.html" %}{% endif %}
{% if field.help_text %}
<span class="help-icon" data-toggle="tooltip"
data-placement="top" title="{{ field.help_text|safe }}">
<span class="fa fa-question-circle"></span>
</span>
{% endif %}
</label>
{% endif %}
{% for error in field.errors %}
<span class="help-block {{ form.error_css_class }}">{{ error }}</span>
{% endfor %}
</div>
{% include 'horizon/common/fields/_themable_checkbox.html' %}
</div>
{% elif field|is_radio %}
{% if field.auto_id %}

View File

@ -3,7 +3,13 @@
<div class="form-group{% if field.errors %} has-error{% endif %} {{ field.css_classes }}">
<label class="control-label col-sm-3 {% if field.field.required %}{{ form.required_css_class }}{% endif %}" for="{{ field.id_for_label }}">{{ field.label }}</label>
<div class="col-sm-9 {{ classes.value }} {{ field|wrapper_classes }}">
{{ field|add_bootstrap_class }}
{% if field|is_checkbox %}
{% with is_vertical=1 %}
{% include 'horizon/common/fields/_themable_checkbox.html' %}
{% endwith %}
{% else %}
{{ field|add_bootstrap_class }}
{% endif %}
{% for error in field.errors %}
<span class="help-block alert alert-danger {{ form.error_css_class }}">{{ error }}</span>
{% empty %}

View File

@ -0,0 +1,24 @@
<div class="themable-checkbox">
{% if field.auto_id %}
{{ field }}
<label
{% if field.field.required and form.required_css_class %}
class="{{ form.required_css_class }}"
{% endif %}
for="{{ field.auto_id }}">
{% if not is_vertical %}
<span>{{ field.label }}</span>
{% endif %}
{% if field.field.required %}{% include "horizon/common/_form_field_required.html" %}{% endif %}
{% if field.help_text %}
<span class="help-icon" data-toggle="tooltip"
data-placement="top" title="{{ field.help_text|safe }}">
<span class="fa fa-question-circle"></span>
</span>
{% endif %}
</label>
{% endif %}
{% for error in field.errors %}
<span class="help-block {{ form.error_css_class }}">{{ error }}</span>
{% endfor %}
</div>

View File

@ -13,6 +13,7 @@
import django.forms
from django import template as django_template
register = django_template.Library()

View File

@ -9,6 +9,7 @@
window.WEBROOT = '{{ WEBROOT }}';
</script>
<script src='{{ STATIC_URL }}horizon/lib/jquery/jquery.js' type='text/javascript' charset="utf-8"></script>
<script src='{{ STATIC_URL }}horizon/lib/jquery-ui/ui/jquery-ui.js' type='text/javascript' charset="utf-8"></script>
<script src='{{ STATIC_URL }}horizon/lib/jquery/jquery-migrate.js' type='text/javascript' charset="utf-8"></script>
<script src="{{ STATIC_URL }}horizon/lib/jquery/jquery.tablesorter.js"></script>
<script src="{{ STATIC_URL }}horizon/lib/angular/angular.js" type="text/javascript" charset="utf-8"></script>
@ -123,4 +124,4 @@
{% endblock %}
</body>
</html>
</html>

View File

@ -338,10 +338,10 @@ class GetUserHomeTests(BaseHorizonTests):
conf.HORIZON_CONFIG._setup()
def test_using_callable(self):
def fancy_user_fnc(user):
def themable_user_fnc(user):
return user.username.upper()
settings.HORIZON_CONFIG['user_home'] = fancy_user_fnc
settings.HORIZON_CONFIG['user_home'] = themable_user_fnc
conf.HORIZON_CONFIG._setup()
self.assertEqual(self.test_user.username.upper(),

View File

@ -37,7 +37,7 @@ class LazyLoadedTabsTests(test.SeleniumTestCase):
table_selector = 'div.tab-content > div#{0} > div.table_wrapper'.format(
tab_id)
button_selector = 'button#lazy_puppies__action_delete'
checkbox_selector = 'td.multi_select_column > input[type=checkbox]'
checkbox_selector = 'td.multi_select_column input[type=checkbox]'
select_all_selector = 'th.multi_select_column input[type=checkbox]'
def setUp(self):

View File

@ -527,6 +527,10 @@
<input type="checkbox"> Checkbox
</label>
</div>
<div class="themable-checkbox">
<input type="checkbox" id="themable-checkbox">
<label for="themable-checkbox" translate>Checkbox</label>
</div>
</div>
</div>
<div class="form-group">

View File

@ -338,7 +338,7 @@ class AddRouterToFirewall(RouterInsertionFormBase):
router_ids = forms.MultipleChoiceField(
label=_("Add Routers"),
required=False,
widget=forms.CheckboxSelectMultiple(),
widget=forms.ThemableCheckboxSelectMultiple(),
help_text=_("Add selected router(s) to the firewall."))
failure_url = 'horizon:project:firewalls:index'
@ -363,7 +363,7 @@ class RemoveRouterFromFirewall(RouterInsertionFormBase):
router_ids = forms.MultipleChoiceField(
label=_("Associated Routers"),
required=False,
widget=forms.CheckboxSelectMultiple(),
widget=forms.ThemableCheckboxSelectMultiple(),
help_text=_("Unselect the router(s) to be removed from firewall."))
failure_url = 'horizon:project:firewalls:index'

View File

@ -164,7 +164,7 @@ class SelectRulesAction(workflows.Action):
rule = forms.MultipleChoiceField(
label=_("Rules"),
required=False,
widget=forms.CheckboxSelectMultiple(),
widget=forms.ThemableCheckboxSelectMultiple(),
help_text=_("Create a policy with selected rules."))
class Meta(object):
@ -206,7 +206,7 @@ class SelectRoutersAction(workflows.Action):
router = forms.MultipleChoiceField(
label=_("Routers"),
required=False,
widget=forms.CheckboxSelectMultiple(),
widget=forms.ThemableCheckboxSelectMultiple(),
help_text=_("Create a firewall with selected routers."))
class Meta(object):

View File

@ -1617,11 +1617,11 @@ class InstanceTests(helpers.TestCase):
else:
self.assertNotContains(res, boot_from_image_field_label)
checked_label = '<label for="id_network_0"><input checked="checked"'
checked_box = '<input checked="checked" id="id_network_0"'
if only_one_network:
self.assertContains(res, checked_label)
self.assertContains(res, checked_box)
else:
self.assertNotContains(res, checked_label)
self.assertNotContains(res, checked_box)
disk_config_field_label = 'Disk Partition'
if disk_config:

View File

@ -551,12 +551,13 @@ class SetAccessControlsAction(workflows.Action):
label=_("Confirm Admin Password"),
required=False,
widget=forms.PasswordInput(render_value=False))
groups = forms.MultipleChoiceField(label=_("Security Groups"),
required=False,
initial=["default"],
widget=forms.CheckboxSelectMultiple(),
help_text=_("Launch instance in these "
"security groups."))
groups = forms.MultipleChoiceField(
label=_("Security Groups"),
required=False,
initial=["default"],
widget=forms.ThemableCheckboxSelectMultiple(),
help_text=_("Launch instance in these "
"security groups."))
class Meta(object):
name = _("Access & Security")
@ -701,14 +702,15 @@ class PostCreationStep(workflows.Step):
class SetNetworkAction(workflows.Action):
network = forms.MultipleChoiceField(label=_("Networks"),
widget=forms.CheckboxSelectMultiple(),
error_messages={
'required': _(
"At least one network must"
" be specified.")},
help_text=_("Launch instance with"
" these networks"))
network = forms.MultipleChoiceField(
label=_("Networks"),
widget=forms.ThemableCheckboxSelectMultiple(),
error_messages={
'required': _(
"At least one network must"
" be specified.")},
help_text=_("Launch instance with"
" these networks"))
if api.neutron.is_port_profiles_supported():
widget = None
else:

View File

@ -0,0 +1,38 @@
@import '/horizon/lib/font-awesome/scss/variables';
@import '/horizon/lib/font-awesome/scss/mixins';
//
// Checkboxes
// This will ONLY work when the label's 'for' attribute
// shares the input[type=checkbox]'s 'id' value
// --------------------------------------------------
.themable-checkbox {
// Hide the real checkbox
input[type=checkbox] {
display:none;
// The checkbox - Unchecked
& + label {
margin-bottom: 0; // remove the Bootstrap margin
&:before {
@include fa-icon();
content: $fa-var-square-o;
width: 1em;
vertical-align: middle;
}
& > span {
padding-left: $padding-small-vertical;
vertical-align: middle;
}
}
// The checkbox - Checked
&:checked + label:before {
content: $fa-var-check-square-o;
}
}
}

View File

@ -17,6 +17,7 @@
// Dashboard Components
@import "components/bar_charts";
@import "components/charts";
@import "components/checkboxes";
@import "components/datepicker";
@import "components/forms";
@import "components/inline_edit";

View File

@ -9,6 +9,8 @@
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import re
import six
from selenium.webdriver.common import by
@ -94,17 +96,31 @@ class CheckBoxMixin(object):
if self.is_marked():
self.element.click()
@property
def name(self):
"""Themable checkboxes use a <label> with font-awesome icon as a
control element while the <input> widget is hidden. Still the name
needs to be extracted from <label>. Attribute "for" is used for that,
since it mirrors <input> "id" which in turn is derived from <input>'s
name.
"""
for_attribute = self.element.get_attribute('for')
indirect_name = re.search(r'id_(.*)', for_attribute)
return (indirect_name and indirect_name.group(1)) or None
class CheckBoxFormFieldRegion(BaseFormFieldRegion, CheckBoxMixin):
class CheckBoxFormFieldRegion(CheckBoxMixin, BaseFormFieldRegion):
"""Checkbox field."""
_element_locator_str_suffix = 'label > input[type=checkbox]'
_element_locator_str_suffix = \
'.themable-checkbox input[type=checkbox] + label'
class ProjectPageCheckBoxFormFieldRegion(BaseFormFieldRegion, CheckBoxMixin):
class ProjectPageCheckBoxFormFieldRegion(CheckBoxMixin, BaseFormFieldRegion):
"""Checkbox field for Project-page."""
_element_locator_str_suffix = 'div > input[type=checkbox]'
_element_locator_str_suffix = \
'div > .themable-checkbox input[type=checkbox] + label'
class ChooseFileFormFieldRegion(BaseFormFieldRegion):

View File

@ -24,7 +24,10 @@ class RowRegion(baseregion.BaseRegion):
"""Classic table row."""
_cell_locator = (by.By.CSS_SELECTOR, 'td.%s' % NORMAL_COLUMN_CLASS)
_row_checkbox_locator = (by.By.CSS_SELECTOR, 'td > input')
_row_checkbox_locator = (
by.By.CSS_SELECTOR,
'td .themable-checkbox [type="checkbox"] + label'
)
def __init__(self, driver, conf, src_elem, column_names):
self.column_names = column_names

View File

@ -36,6 +36,10 @@ form label {
}
}
.help-icon {
color: $gray;
}
// Note (hurgleburgler) : the combination of display: table-cell, width: 100%
// and max-width has strange consequences in CSS. This is required to make sure
// that the width does not stretch beyond 100%. Please see the answer to

View File

@ -1,6 +1,7 @@
@import "icons";
@import "components/context_selection";
@import "components/messages";
@import "components/checkboxes";
@import "components/hamburger";
@import "components/magic_search";
@import "components/navbar";

View File

@ -0,0 +1,29 @@
//
// Custom Material Checkboxes
// --------------------------------------------------
@import "/horizon/lib/mdi/scss/icons";
.themable-checkbox {
input[type=checkbox] {
// The checkbox - Unchecked
& + label {
@extend .mdi-checkbox-blank-outline;
&:before {
@extend .mdi;
font-size: $font-size-h5;
}
}
// The checkbox - Checked
&:checked + label {
@extend .mdi-checkbox-marked;
&:before {
color: $brand-primary;
}
}
}
}