Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix keyboard handling of button-style checkbox/radio button groups #28834

Merged
merged 31 commits into from
Jun 5, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
5a81033
Fix keyboard behaviour for button plugin checkbox/radio buttons
patrickhlauke May 27, 2019
639a2a2
Make additional checkbox handling only apply when click happened on a…
patrickhlauke May 27, 2019
847cd68
Change toggle selectable inputs unit test
patrickhlauke May 27, 2019
1206b75
Split out check for disabled input and disabled root element and do i…
patrickhlauke May 29, 2019
8fb8b9a
Tweak test to avoid reliance on browser default behaviour which curre…
patrickhlauke May 29, 2019
caf7353
Further tweak to qunit test
patrickhlauke May 29, 2019
b8f0a3b
Test checked state of input using prop() rather than :checked
patrickhlauke May 29, 2019
d35e5de
Expand test to more explicitly check preconditions
patrickhlauke May 29, 2019
515a478
Change === to == comparison
patrickhlauke May 29, 2019
c926bc4
Undo last change of === to ==
patrickhlauke May 29, 2019
088f144
Revert hacky tweak to "toggle selectable inputs" test but use proper …
patrickhlauke May 29, 2019
0f3818f
Changing toggle selectable inputs test
patrickhlauke May 29, 2019
6b5588a
Removing contrived/non-real world test
patrickhlauke May 29, 2019
5311b8c
Add missing handling for aria-pressed toggle buttons with disabled class
patrickhlauke May 29, 2019
9423718
Fix Firefox issue
patrickhlauke May 29, 2019
eb025e1
Increase allowed complexity in eslint
patrickhlauke May 29, 2019
762d24f
reset eslintrc
patrickhlauke May 29, 2019
9993680
Remove now unnecessary extra defensive check
patrickhlauke May 29, 2019
c750578
Merge branch 'v4-dev' into patrickhlauke-button-plugin-kbd-fix
patrickhlauke Jun 4, 2019
b4e9349
Add basic unit test for <label><input></label> behaviour
patrickhlauke Jun 4, 2019
43770d9
Remove potentially unnecessary check
patrickhlauke Jun 4, 2019
29ba162
Add check for button without disabled class but actually disabled inn…
patrickhlauke Jun 4, 2019
46259f2
Revert "Add check for button without disabled class but actually disa…
patrickhlauke Jun 4, 2019
06ea53e
Move disabled input check to work around Firefox bug into initial han…
patrickhlauke Jun 4, 2019
6d1b78f
Add check for button without disabled class but actually disabled inn…
patrickhlauke Jun 4, 2019
4499795
More unit tests
patrickhlauke Jun 5, 2019
fc41c16
Extra check of input type, tighten event handler code
patrickhlauke Jun 5, 2019
1e685b3
More unit tests
patrickhlauke Jun 5, 2019
c4dc4b1
Add test for less-than-ideal <div><input></div> structure
patrickhlauke Jun 5, 2019
9dc27a4
Store value for the if
patrickhlauke Jun 5, 2019
30b5f23
Update button.js
XhmikosR Jun 5, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 28 additions & 16 deletions js/src/button.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,16 @@ class Button {
$(activeElement).removeClass(ClassName.ACTIVE)
}
}
} else if (input.type === 'checkbox') {
if (this._element.tagName === 'LABEL' && input.checked === this._element.classList.contains(ClassName.ACTIVE)) {
triggerChangeEvent = false
}
} else {
// if it's not a radio button or checkbox don't add a pointless/invalid checked property to the input
triggerChangeEvent = false
}

if (triggerChangeEvent) {
if (input.hasAttribute('disabled') ||
rootElement.hasAttribute('disabled') ||
input.classList.contains('disabled') ||
rootElement.classList.contains('disabled')) {
return
}
input.checked = !this._element.classList.contains(ClassName.ACTIVE)
$(input).trigger('change')
}
Expand All @@ -99,13 +100,15 @@ class Button {
}
}

if (addAriaPressed) {
this._element.setAttribute('aria-pressed',
!this._element.classList.contains(ClassName.ACTIVE))
}
if (!(this._element.hasAttribute('disabled') || this._element.classList.contains('disabled'))) {
if (addAriaPressed) {
this._element.setAttribute('aria-pressed',
!this._element.classList.contains(ClassName.ACTIVE))
}

if (triggerChangeEvent) {
$(this._element).toggleClass(ClassName.ACTIVE)
if (triggerChangeEvent) {
$(this._element).toggleClass(ClassName.ACTIVE)
}
}
}

Expand Down Expand Up @@ -140,15 +143,24 @@ class Button {

$(document)
.on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE_CARROT, (event) => {
event.preventDefault()

let button = event.target

if (!$(button).hasClass(ClassName.BUTTON)) {
button = $(button).closest(Selector.BUTTON)
button = $(button).closest(Selector.BUTTON)[0]
}

Button._jQueryInterface.call($(button), 'toggle')
if (!button || button.hasAttribute('disabled') || button.classList.contains('disabled')) {
event.preventDefault() // work around Firefox bug #1540995
} else {
const inputBtn = button.querySelector(Selector.INPUT)

if (inputBtn && (inputBtn.hasAttribute('disabled') || inputBtn.classList.contains('disabled'))) {
event.preventDefault() // work around Firefox bug #1540995
return
}

Button._jQueryInterface.call($(button), 'toggle')
}
})
.on(Event.FOCUS_BLUR_DATA_API, Selector.DATA_TOGGLE_CARROT, (event) => {
const button = $(event.target).closest(Selector.BUTTON)[0]
Expand Down
161 changes: 133 additions & 28 deletions js/tests/unit/button.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@ $(function () {
assert.strictEqual($btn.attr('aria-pressed'), 'true', 'btn aria-pressed state is true')
})

QUnit.test('should not toggle aria-pressed on buttons with disabled class', function (assert) {
assert.expect(2)
var $btn = $('<button class="btn disabled" data-toggle="button" aria-pressed="false">redux</button>')
assert.strictEqual($btn.attr('aria-pressed'), 'false', 'btn aria-pressed state is false')
$btn.bootstrapButton('toggle')
assert.strictEqual($btn.attr('aria-pressed'), 'false', 'btn aria-pressed state is still false')
})

QUnit.test('should not toggle aria-pressed on buttons that are disabled', function (assert) {
assert.expect(2)
var $btn = $('<button class="btn" data-toggle="button" aria-pressed="false" disabled>redux</button>')
assert.strictEqual($btn.attr('aria-pressed'), 'false', 'btn aria-pressed state is false')
$btn.bootstrapButton('toggle')
assert.strictEqual($btn.attr('aria-pressed'), 'false', 'btn aria-pressed state is still false')
})

QUnit.test('should toggle aria-pressed on buttons with container', function (assert) {
assert.expect(1)
var groupHTML = '<div class="btn-group" data-toggle="buttons">' +
Expand Down Expand Up @@ -139,29 +155,6 @@ $(function () {
assert.ok($btn2.find('input').prop('checked'), 'btn2 is checked')
})

QUnit.test('should only toggle selectable inputs', function (assert) {
assert.expect(6)
var groupHTML = '<div class="btn-group" data-toggle="buttons">' +
'<label class="btn btn-primary active">' +
'<input type="hidden" name="option1" id="option1-default" value="false">' +
'<input type="checkbox" name="option1" id="option1" checked="true"> Option 1' +
'</label>' +
'</div>'
var $group = $(groupHTML).appendTo('#qunit-fixture')

var $btn = $group.children().eq(0)
var $hidden = $btn.find('input#option1-default')
var $cb = $btn.find('input#option1')

assert.ok($btn.hasClass('active'), 'btn has active class')
assert.ok($cb.prop('checked'), 'btn is checked')
assert.ok(!$hidden.prop('checked'), 'hidden is not checked')
$btn.trigger('click')
assert.ok(!$btn.hasClass('active'), 'btn does not have active class')
assert.ok(!$cb.prop('checked'), 'btn is not checked')
assert.ok(!$hidden.prop('checked'), 'hidden is not checked') // should not be changed
})

QUnit.test('should not add aria-pressed on labels for radio/checkbox inputs in a data-toggle="buttons" group', function (assert) {
assert.expect(2)
var groupHTML = '<div class="btn-group" data-toggle="buttons">' +
Expand All @@ -181,20 +174,132 @@ $(function () {
})

QUnit.test('should handle disabled attribute on non-button elements', function (assert) {
assert.expect(2)
assert.expect(4)
var groupHTML = '<div class="btn-group disabled" data-toggle="buttons" aria-disabled="true" disabled>' +
'<label class="btn btn-danger disabled" aria-disabled="true" disabled>' +
'<input type="checkbox" aria-disabled="true" autocomplete="off" disabled class="disabled"/>' +
'<label class="btn btn-danger disabled">' +
'<input type="checkbox" aria-disabled="true" autocomplete="off" disabled>' +
'</label>' +
'</div>'
var $group = $(groupHTML).appendTo('#qunit-fixture')

var $btn = $group.children().eq(0)
var $input = $btn.children().eq(0)

$btn.trigger('click')
assert.ok($btn.is(':not(.active)'), 'button is initially not active')
assert.ok(!$input.prop('checked'), 'checkbox is initially not checked')
$btn[0].click() // fire a real click on the DOM node itself, not a click() on the jQuery object that just aliases to trigger('click')
assert.ok($btn.is(':not(.active)'), 'button did not become active')
assert.ok(!$input.is(':checked'), 'checkbox did not get checked')
assert.ok(!$input.prop('checked'), 'checkbox did not get checked')
})

QUnit.test('should not set active class if inner hidden checkbox is disabled but author forgot to set disabled class on outer button', function (assert) {
assert.expect(4)
var groupHTML = '<div class="btn-group" data-toggle="buttons">' +
'<label class="btn btn-danger">' +
'<input type="checkbox" autocomplete="off" disabled>' +
'</label>' +
'</div>'
var $group = $(groupHTML).appendTo('#qunit-fixture')

var $btn = $group.children().eq(0)
var $input = $btn.children().eq(0)

assert.ok($btn.is(':not(.active)'), 'button is initially not active')
assert.ok(!$input.prop('checked'), 'checkbox is initially not checked')
$btn[0].click() // fire a real click on the DOM node itself, not a click() on the jQuery object that just aliases to trigger('click')
assert.ok($btn.is(':not(.active)'), 'button did not become active')
assert.ok(!$input.prop('checked'), 'checkbox did not get checked')
})

QUnit.test('should correctly set checked state on input and active class on label when using <label><input></label> structure', function (assert) {
assert.expect(4)
var groupHTML = '<div class="btn-group" data-toggle="buttons">' +
'<label class="btn">' +
'<input type="checkbox" autocomplete="off">' +
'</label>' +
'</div>'
var $group = $(groupHTML).appendTo('#qunit-fixture')

var $label = $group.children().eq(0)
var $input = $label.children().eq(0)

assert.ok($label.is(':not(.active)'), 'label is initially not active')
assert.ok(!$input.prop('checked'), 'checkbox is initially not checked')
$label[0].click() // fire a real click on the DOM node itself, not a click() on the jQuery object that just aliases to trigger('click')
assert.ok($label.is('.active'), 'label is active after click')
assert.ok($input.prop('checked'), 'checkbox is checked after click')
})

QUnit.test('should correctly set checked state on input and active class on the faked button when using <div><input></div> structure', function (assert) {
assert.expect(4)
var groupHTML = '<div class="btn-group" data-toggle="buttons">' +
'<div class="btn">' +
'<input type="checkbox" autocomplete="off" aria-label="Check">' +
'</div>' +
'</div>'
var $group = $(groupHTML).appendTo('#qunit-fixture')

var $btn = $group.children().eq(0)
var $input = $btn.children().eq(0)

assert.ok($btn.is(':not(.active)'), '<div> is initially not active')
assert.ok(!$input.prop('checked'), 'checkbox is initially not checked')
$btn[0].click() // fire a real click on the DOM node itself, not a click() on the jQuery object that just aliases to trigger('click')
assert.ok($btn.is('.active'), '<div> is active after click')
assert.ok($input.prop('checked'), 'checkbox is checked after click')
})

QUnit.test('should not do anything if the click was just sent to the outer container with data-toggle', function (assert) {
assert.expect(4)
var groupHTML = '<div class="btn-group" data-toggle="buttons">' +
'<label class="btn">' +
'<input type="checkbox" autocomplete="off">' +
'</label>' +
'</div>'
var $group = $(groupHTML).appendTo('#qunit-fixture')

var $label = $group.children().eq(0)
var $input = $label.children().eq(0)

assert.ok($label.is(':not(.active)'), 'label is initially not active')
assert.ok(!$input.prop('checked'), 'checkbox is initially not checked')
$group[0].click() // fire a real click on the DOM node itself, not a click() on the jQuery object that just aliases to trigger('click')
assert.ok($label.is(':not(.active)'), 'label is not active after click')
assert.ok(!$input.prop('checked'), 'checkbox is not checked after click')
})

QUnit.test('should not try and set checked property on an input of type="hidden"', function (assert) {
assert.expect(2)
var groupHTML = '<div class="btn-group" data-toggle="buttons">' +
'<label class="btn">' +
'<input type="hidden" autocomplete="off">' +
'</label>' +
'</div>'
var $group = $(groupHTML).appendTo('#qunit-fixture')

var $label = $group.children().eq(0)
var $input = $label.children().eq(0)

assert.ok(!$input.prop('checked'), 'hidden input initially has no checked property')
$label[0].click() // fire a real click on the DOM node itself, not a click() on the jQuery object that just aliases to trigger('click')
assert.ok(!$input.prop('checked'), 'hidden input does not have a checked property')
})

QUnit.test('should not try and set checked property on an input that is not a radio button or checkbox', function (assert) {
assert.expect(2)
var groupHTML = '<div class="btn-group" data-toggle="buttons">' +
'<label class="btn">' +
'<input type="text" autocomplete="off">' +
'</label>' +
'</div>'
var $group = $(groupHTML).appendTo('#qunit-fixture')

var $label = $group.children().eq(0)
var $input = $label.children().eq(0)

assert.ok(!$input.prop('checked'), 'text input initially has no checked property')
$label[0].click() // fire a real click on the DOM node itself, not a click() on the jQuery object that just aliases to trigger('click')
assert.ok(!$input.prop('checked'), 'text input does not have a checked property')
})

QUnit.test('dispose should remove data and the element', function (assert) {
Expand Down