1
0
Fork 0
mirror of https://github.com/twbs/bootstrap.git synced 2022-11-09 12:25:43 -05:00

Remove checkbox/radio toggle from button plugin in favor of a CSS only solution

This commit is contained in:
Martijn Cuppens 2020-05-02 11:11:24 +02:00 committed by Mark Otto
parent 1b2ea5efb1
commit 1a0a0858ef
10 changed files with 122 additions and 317 deletions

View file

@ -8,7 +8,6 @@
import { getjQuery } from './util/index'
import Data from './dom/data'
import EventHandler from './dom/event-handler'
import SelectorEngine from './dom/selector-engine'
/**
* ------------------------------------------------------------------------
@ -23,18 +22,10 @@ const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const CLASS_NAME_ACTIVE = 'active'
const CLASS_NAME_DISABLED = 'disabled'
const CLASS_NAME_FOCUS = 'focus'
const SELECTOR_DATA_TOGGLE_CARROT = '[data-toggle^="button"]'
const SELECTOR_DATA_TOGGLE = '[data-toggle="buttons"]'
const SELECTOR_INPUT = 'input:not([type="hidden"])'
const SELECTOR_ACTIVE = '.active'
const SELECTOR_BUTTON = '.btn'
const SELECTOR_DATA_TOGGLE = '[data-toggle="button"]'
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
const EVENT_FOCUS_DATA_API = `focus${EVENT_KEY}${DATA_API_KEY}`
const EVENT_BLUR_DATA_API = `blur${EVENT_KEY}${DATA_API_KEY}`
/**
* ------------------------------------------------------------------------
@ -57,51 +48,8 @@ class Button {
// Public
toggle() {
let triggerChangeEvent = true
let addAriaPressed = true
const rootElement = this._element.closest(SELECTOR_DATA_TOGGLE)
if (rootElement) {
const input = SelectorEngine.findOne(SELECTOR_INPUT, this._element)
if (input && input.type === 'radio') {
if (input.checked &&
this._element.classList.contains(CLASS_NAME_ACTIVE)) {
triggerChangeEvent = false
} else {
const activeElement = SelectorEngine.findOne(SELECTOR_ACTIVE, rootElement)
if (activeElement) {
activeElement.classList.remove(CLASS_NAME_ACTIVE)
}
}
if (triggerChangeEvent) {
if (input.hasAttribute('disabled') ||
rootElement.hasAttribute('disabled') ||
input.classList.contains(CLASS_NAME_DISABLED) ||
rootElement.classList.contains(CLASS_NAME_DISABLED)) {
return
}
input.checked = !this._element.classList.contains(CLASS_NAME_ACTIVE)
EventHandler.trigger(input, 'change')
}
input.focus()
addAriaPressed = false
}
}
if (addAriaPressed) {
this._element.setAttribute('aria-pressed',
!this._element.classList.contains(CLASS_NAME_ACTIVE))
}
if (triggerChangeEvent) {
this._element.classList.toggle(CLASS_NAME_ACTIVE)
}
// Toggle class and sync the `aria-pressed` attribute with the return value of the `.toggle()` method
this._element.setAttribute('aria-pressed', this._element.classList.toggle(CLASS_NAME_ACTIVE))
}
dispose() {
@ -136,10 +84,10 @@ class Button {
* ------------------------------------------------------------------------
*/
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE_CARROT, event => {
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, event => {
event.preventDefault()
const button = event.target.closest(SELECTOR_BUTTON)
const button = event.target.closest(SELECTOR_DATA_TOGGLE)
let data = Data.getData(button, DATA_KEY)
if (!data) {
@ -149,22 +97,6 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE_CARROT, eve
data.toggle()
})
EventHandler.on(document, EVENT_FOCUS_DATA_API, SELECTOR_DATA_TOGGLE_CARROT, event => {
const button = event.target.closest(SELECTOR_BUTTON)
if (button) {
button.classList.add(CLASS_NAME_FOCUS)
}
})
EventHandler.on(document, EVENT_BLUR_DATA_API, SELECTOR_DATA_TOGGLE_CARROT, event => {
const button = event.target.closest(SELECTOR_BUTTON)
if (button) {
button.classList.remove(CLASS_NAME_FOCUS)
}
})
const $ = getjQuery()
/**

View file

@ -1,11 +1,9 @@
import Button from '../../src/button'
import EventHandler from '../../src/dom/event-handler'
/** Test helpers */
import {
getFixture,
clearFixture,
createEvent,
jQueryMock
} from '../helpers/fixture'
@ -51,144 +49,6 @@ describe('Button', () => {
expect(btnTestParent.classList.contains('active')).toEqual(true)
})
it('should trigger input change event when toggled button has input field', done => {
fixtureEl.innerHTML = [
'<div class="btn-group" data-toggle="buttons">',
' <label class="btn btn-primary">',
' <input type="radio" id="radio" autocomplete="off"> Radio',
' </label>',
'</div>'
].join('')
const input = fixtureEl.querySelector('input')
const label = fixtureEl.querySelector('label')
input.addEventListener('change', () => {
expect().nothing()
done()
})
label.click()
})
it('should not trigger input change event when input already checked and button is active', () => {
fixtureEl.innerHTML = [
'<button type="button" class="btn btn-primary active" data-toggle="buttons">',
' <input type="radio" id="radio" autocomplete="off" checked> Radio',
'</button>'
].join('')
const button = fixtureEl.querySelector('button')
spyOn(EventHandler, 'trigger')
button.click()
expect(EventHandler.trigger).not.toHaveBeenCalled()
})
it('should remove active when an other radio button is clicked', () => {
fixtureEl.innerHTML = [
'<div class="btn-group btn-group-toggle" data-toggle="buttons">',
' <label class="btn btn-secondary active">',
' <input type="radio" name="options" id="option1" autocomplete="off" checked> Active',
' </label>',
' <label class="btn btn-secondary">',
' <input type="radio" name="options" id="option2" autocomplete="off"> Radio',
' </label>',
' <label class="btn btn-secondary">',
' <input type="radio" name="options" id="option3" autocomplete="off"> Radio',
' </label>',
'</div>'
].join('')
const option1 = fixtureEl.querySelector('#option1')
const option2 = fixtureEl.querySelector('#option2')
expect(option1.checked).toEqual(true)
expect(option1.parentElement.classList.contains('active')).toEqual(true)
const clickEvent = createEvent('click')
option2.dispatchEvent(clickEvent)
expect(option1.checked).toEqual(false)
expect(option1.parentElement.classList.contains('active')).toEqual(false)
expect(option2.checked).toEqual(true)
expect(option2.parentElement.classList.contains('active')).toEqual(true)
})
it('should do nothing if the child is not an input', () => {
fixtureEl.innerHTML = [
'<div class="btn-group btn-group-toggle" data-toggle="buttons">',
' <label class="btn btn-secondary active">',
' <span id="option1">el 1</span>',
' </label>',
' <label class="btn btn-secondary">',
' <span id="option2">el 2</span>',
' </label>',
' <label class="btn btn-secondary">',
' <span>el 3</span>',
' </label>',
'</div>'
].join('')
const option2 = fixtureEl.querySelector('#option2')
const clickEvent = createEvent('click')
option2.dispatchEvent(clickEvent)
expect().nothing()
})
it('should add focus class on focus event', () => {
fixtureEl.innerHTML = '<button class="btn" data-toggle="button"><input type="text"></button>'
const btn = fixtureEl.querySelector('.btn')
const input = fixtureEl.querySelector('input')
const focusEvent = createEvent('focus')
input.dispatchEvent(focusEvent)
expect(btn.classList.contains('focus')).toEqual(true)
})
it('should not add focus class', () => {
fixtureEl.innerHTML = '<button data-toggle="button"><input type="text"></button>'
const btn = fixtureEl.querySelector('button')
const input = fixtureEl.querySelector('input')
const focusEvent = createEvent('focus')
input.dispatchEvent(focusEvent)
expect(btn.classList.contains('focus')).toEqual(false)
})
it('should remove focus class on blur event', () => {
fixtureEl.innerHTML = '<button class="btn focus" data-toggle="button"><input type="text"></button>'
const btn = fixtureEl.querySelector('.btn')
const input = fixtureEl.querySelector('input')
const focusEvent = createEvent('blur')
input.dispatchEvent(focusEvent)
expect(btn.classList.contains('focus')).toEqual(false)
})
it('should not remove focus class on blur event', () => {
fixtureEl.innerHTML = '<button class="focus" data-toggle="button"><input type="text"></button>'
const btn = fixtureEl.querySelector('button')
const input = fixtureEl.querySelector('input')
const focusEvent = createEvent('blur')
input.dispatchEvent(focusEvent)
expect(btn.classList.contains('focus')).toEqual(true)
})
})
describe('toggle', () => {
@ -206,27 +66,6 @@ describe('Button', () => {
expect(btnEl.getAttribute('aria-pressed')).toEqual('true')
expect(btnEl.classList.contains('active')).toEqual(true)
})
it('should handle disabled attribute on non-button elements', () => {
fixtureEl.innerHTML = [
'<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>',
'</div>'
].join('')
const btnGroupEl = fixtureEl.querySelector('.btn-group')
const btnDanger = fixtureEl.querySelector('.btn-danger')
const input = fixtureEl.querySelector('input')
const button = new Button(btnGroupEl)
button.toggle()
expect(btnDanger.hasAttribute('disabled')).toEqual(true)
expect(input.checked).toEqual(false)
})
})
describe('dispose', () => {

View file

@ -10,15 +10,17 @@
> .btn {
position: relative;
flex: 1 1 auto;
}
// Bring the hover, focused, and "active" buttons to the front to overlay
// the borders properly
&:hover,
&:focus,
&:active,
&.active {
z-index: 1;
}
// Bring the hover, focused, and "active" buttons to the front to overlay
// the borders properly
> .btn-toggle:checked + .btn,
> .btn-toggle:focus + .btn,
> .btn:hover,
> .btn:focus,
> .btn:active,
> .btn.active {
z-index: 1;
}
}
@ -46,7 +48,11 @@
@include border-right-radius(0);
}
> .btn:not(:first-child),
// - Target second buttons which are not part of toggle buttons
// - Target third or more child
// - Target buttons in a button group
> :not(.btn-toggle) + .btn,
> .btn:nth-child(n + 3),
> .btn-group:not(:first-child) > .btn {
@include border-left-radius(0);
}
@ -132,28 +138,3 @@
@include border-top-radius(0);
}
}
// Checkbox and radio options
//
// In order to support the browser's form validation feedback, powered by the
// `required` attribute, we have to "hide" the inputs via `clip`. We cannot use
// `display: none;` or `visibility: hidden;` as that also hides the popover.
// Simply visually hiding the inputs via `opacity` would leave them clickable in
// certain cases which is prevented by using `clip` and `pointer-events`.
// This way, we ensure a DOM element is visible to position the popover from.
//
// See https://github.com/twbs/bootstrap/pull/12794 and
// https://github.com/twbs/bootstrap/pull/14559 for more information.
.btn-group-toggle {
> .btn,
> .btn-group > .btn {
input[type="radio"],
input[type="checkbox"] {
position: absolute;
clip: rect(0, 0, 0, 0);
pointer-events: none;
}
}
}

View file

@ -24,12 +24,14 @@
text-decoration: if($link-hover-decoration == underline, none, null);
}
&:focus,
&.focus {
.btn-toggle:focus + &,
&:focus {
outline: 0;
box-shadow: $btn-focus-box-shadow;
}
.btn-toggle:checked + &,
.btn-toggle:active + &,
&:active,
&.active {
@include box-shadow($btn-active-box-shadow);
@ -81,8 +83,7 @@
text-decoration: $link-hover-decoration;
}
&:focus,
&.focus {
&:focus {
text-decoration: $link-hover-decoration;
}

View file

@ -134,3 +134,9 @@
display: inline-block;
margin-right: $form-check-inline-margin-right;
}
.btn-toggle {
position: absolute;
clip: rect(0, 0, 0, 0);
pointer-events: none;
}

View file

@ -25,8 +25,8 @@
border-color: $hover-border;
}
&:focus,
&.focus {
.btn-toggle:focus + &,
&:focus {
color: $hover-color;
@include gradient-bg($hover-background);
border-color: $hover-border;
@ -38,6 +38,8 @@
}
}
.btn-toggle:checked + &,
.btn-toggle:active + &,
&:active,
&.active,
.show > &.dropdown-toggle {
@ -83,11 +85,13 @@
border-color: $active-border;
}
&:focus,
&.focus {
.btn-toggle:focus + &,
&:focus {
box-shadow: 0 0 0 $btn-focus-width rgba($color, .5);
}
.btn-toggle:checked + &,
.btn-toggle:active + &,
&:active,
&.active,
&.dropdown-toggle.show {

View file

@ -1,14 +1,14 @@
---
layout: docs
title: Button group
description: Group a series of buttons together on a single line with the button group, and super-power them with JavaScript.
description: Group a series of buttons together on a single line with the button group.
group: components
toc: true
---
## Basic example
Wrap a series of buttons with `.btn` in `.btn-group`. Add on optional JavaScript radio and checkbox style behavior with [our buttons plugin]({{< docsref "/components/buttons#button-plugin" >}}).
Wrap a series of buttons with `.btn` in `.btn-group`.
{{< example >}}
<div class="btn-group" role="group" aria-label="Basic example">
@ -26,6 +26,26 @@ In order for assistive technologies (such as screen readers) to convey that a se
In addition, groups and toolbars should be given an explicit label, as most assistive technologies will otherwise not announce them, despite the presence of the correct role attribute. In the examples provided here, we use `aria-label`, but alternatives such as `aria-labelledby` can also be used.
{{< /callout >}}
These classes can also be added to links. Use the `.active` class to highlight a link.
{{< example >}}
<div class="btn-group">
<a href="#" class="btn btn-secondary active">Active link</a>
<a href="#" class="btn btn-secondary">Link</a>
<a href="#" class="btn btn-secondary">Link</a>
</div>
{{< /example >}}
## Outlined styles
{{< example >}}
<div class="btn-group" role="group" aria-label="Basic example">
<button type="button" class="btn btn-outline-secondary">Left</button>
<button type="button" class="btn btn-outline-secondary">Middle</button>
<button type="button" class="btn btn-outline-secondary">Right</button>
</div>
{{< /example >}}
## Button toolbar
Combine sets of button groups into button toolbars for more complex components. Use utility classes as needed to space out groups, buttons, and more.

View file

@ -75,15 +75,6 @@ Create block level buttons—those that span the full width of a parent—by add
<button type="button" class="btn btn-secondary btn-lg btn-block">Block level button</button>
{{< /example >}}
## Active state
Buttons will appear pressed (with a darker background, darker border, and inset shadow) when active. **There's no need to add a class to `<button>`s as they use a pseudo-class**. However, you can still force the same active appearance with `.active` (and include the <code>aria-pressed="true"</code> attribute) should you need to replicate the state programmatically.
{{< example >}}
<a href="#" class="btn btn-primary btn-lg active" role="button" aria-pressed="true">Primary link</a>
<a href="#" class="btn btn-secondary btn-lg active" role="button" aria-pressed="true">Link</a>
{{< /example >}}
## Disabled state
Make buttons look inactive by adding the `disabled` boolean attribute to any `<button>` element. Disabled buttons have `pointer-events: none` applied to, preventing hover and active states from triggering.
@ -119,39 +110,15 @@ Do more with buttons. Control button states or create groups of buttons for more
Add `data-toggle="button"` to toggle a button's `active` state. If you're pre-toggling a button, you must manually add the `.active` class **and** `aria-pressed="true"` to the `<button>`.
{{< example >}}
<button type="button" class="btn btn-primary" data-toggle="button" aria-pressed="false" autocomplete="off">
Single toggle
</button>
{{< /example >}}
### Checkbox and radio buttons
Bootstrap's `.button` styles can be applied to other elements, such as `<label>`s, to provide checkbox or radio style button toggling. Add `data-toggle="buttons"` to a `.btn-group` containing those modified buttons to enable their toggling behavior via JavaScript and add `.btn-group-toggle` to style the `<input>`s within your buttons. **Note that you can create single input-powered buttons or groups of them.**
The checked state for these buttons is **only updated via `click` event** on the button. If you use another method to update the input—e.g., with `<input type="reset">` or by manually applying the input's `checked` property—you'll need to toggle `.active` on the `<label>` manually.
Note that pre-checked buttons require you to manually add the `.active` class to the input's `<label>`.
{{< example >}}
<div class="btn-group-toggle" data-toggle="buttons">
<label class="btn btn-secondary active">
<input type="checkbox" checked autocomplete="off"> Checked
</label>
</div>
<button type="button" class="btn btn-primary" data-toggle="button" autocomplete="off">Toggle button</button>
<button type="button" class="btn btn-primary active" data-toggle="button" autocomplete="off" aria-pressed="true">Active toggle button</button>
<button type="button" class="btn btn-primary" disabled data-toggle="button" autocomplete="off">Disabled toggle button</button>
{{< /example >}}
{{< example >}}
<div class="btn-group btn-group-toggle" data-toggle="buttons">
<label class="btn btn-secondary active">
<input type="radio" name="options" id="option1" autocomplete="off" checked> Active
</label>
<label class="btn btn-secondary">
<input type="radio" name="options" id="option2" autocomplete="off"> Radio
</label>
<label class="btn btn-secondary">
<input type="radio" name="options" id="option3" autocomplete="off"> Radio
</label>
</div>
<a href="#" class="btn btn-primary" role="button" data-toggle="button">Toggle link</a>
<a href="#" class="btn btn-primary active" role="button" data-toggle="button" aria-pressed="true">Active toggle link</a>
<a href="#" class="btn btn-primary disabled" role="button" data-toggle="button">Disabled toggle link</a>
{{< /example >}}
### Methods

View file

@ -209,3 +209,54 @@ Omit the wrapping `.form-check` for checkboxes and radios that have no label tex
<input class="form-check-input" type="radio" name="radioNoLabel" id="radioNoLabel1" value="" aria-label="...">
</div>
{{< /example >}}
## Toggle buttons
### Checkbox toggle buttons
Bootstrap's `.btn` styles can be applied to `<label>`s, to provide checkbox style button toggling. Add an input with a `.btn-toggle` class as previous sibling to toggle the input state.
{{< example >}}
<input type="checkbox" class="btn-toggle" id="btn-toggle" autocomplete="off">
<label class="btn btn-primary" for="btn-toggle">Single toggle</label>
{{< /example >}}
{{< example >}}
<input type="checkbox" class="btn-toggle" id="btn-toggle-2" checked autocomplete="off">
<label class="btn btn-primary" for="btn-toggle-2">Checked</label>
{{< /example >}}
### Radio toggle buttons
Toggle buttons can be grouped in a [button group]({{< docsref "/components/button-group" >}}) if needed.
{{< example >}}
<div class="btn-group">
<input type="radio" class="btn-toggle" name="options" id="option1" autocomplete="off" checked>
<label class="btn btn-secondary" for="option1">Checked</label>
<input type="radio" class="btn-toggle" name="options" id="option2" autocomplete="off">
<label class="btn btn-secondary" for="option2">Radio</label>
<input type="radio" class="btn-toggle" name="options" id="option3" autocomplete="off">
<label class="btn btn-secondary" for="option3">Radio</label>
</div>
{{< /example >}}
### Outlined styles
{{< example >}}
<input type="checkbox" class="btn-toggle" id="btn-toggle-outlined" autocomplete="off">
<label class="btn btn-outline-primary" for="btn-toggle-outlined">Single toggle</label><br>
<input type="checkbox" class="btn-toggle" id="btn-toggle-2-outlined" checked autocomplete="off">
<label class="btn btn-outline-secondary" for="btn-toggle-2-outlined">Checked</label><br>
<div class="btn-group">
<input type="radio" class="btn-toggle" name="options-outlined" id="success-outlined" autocomplete="off" checked>
<label class="btn btn-outline-success" for="success-outlined">Checked success radio</label>
<input type="radio" class="btn-toggle" name="options-outlined" id="danger-outlined" autocomplete="off">
<label class="btn btn-outline-danger" for="danger-outlined">Danger radio</label>
</div>
{{< /example >}}

View file

@ -148,6 +148,10 @@ Badges were overhauled to better differentiate themselves from buttons and to be
- **Todo:** Removed `.badge-pill` for the `.rounded-pill` utility class
- **Todo:** Removed badge's hover and focus styles for `a.badge` and `button.badge`.
### Buttons
- The checkbox/radio toggle is removed from the button plugin in favour of a CSS only solution, which is documented in the [form checks]({{< docsref "/forms/checks#toggle-buttons" >}}) docs. The `.btn-toggle` class can be added to inputs, any label with `.btn` and modifier class can be used to theme the labels. [See #30650](https://github.com/twbs/bootstrap/pull/30650).
### Cards
- Removed the card columns in favor of a Masonry grid [See #28922](https://github.com/twbs/bootstrap/pull/28922).