diff --git a/js/index.esm.js b/js/index.esm.js index 4a8bf422bd..78bb7b47bc 100644 --- a/js/index.esm.js +++ b/js/index.esm.js @@ -15,6 +15,7 @@ export { default as Dropdown } from './src/dropdown' export { default as Modal } from './src/modal' export { default as Offcanvas } from './src/offcanvas' export { default as Popover } from './src/popover' +export { default as QuantitySelector } from './src/quantity-selector' // Boosted mod export { default as ScrollSpy } from './src/scrollspy' export { default as Tab } from './src/tab' export { default as Toast } from './src/toast' diff --git a/js/index.umd.js b/js/index.umd.js index b5ec086c67..9d778e24da 100644 --- a/js/index.umd.js +++ b/js/index.umd.js @@ -13,6 +13,7 @@ import Dropdown from './src/dropdown' import Modal from './src/modal' import Offcanvas from './src/offcanvas' import Popover from './src/popover' +import QuantitySelector from './src/quantity-selector' // Boosted mod import ScrollSpy from './src/scrollspy' import Tab from './src/tab' import Toast from './src/toast' @@ -28,6 +29,7 @@ export default { Modal, Offcanvas, Popover, + QuantitySelector, // Boosted mod ScrollSpy, Tab, Toast, diff --git a/js/src/quantity-selector.js b/js/src/quantity-selector.js new file mode 100644 index 0000000000..7beab3a88c --- /dev/null +++ b/js/src/quantity-selector.js @@ -0,0 +1,79 @@ +/** + * -------------------------------------------------------------------------- + * Boosted (v5.1.3): quantity-selector.js + * Licensed under MIT (https://github.com/Orange-OpenSource/Orange-Boosted-Bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import { defineJQueryPlugin } from './util/index' +import EventHandler from './dom/event-handler' +import BaseComponent from './base-component' + +/** + * Constants + */ + +const NAME = 'quantityselector' +const DATA_KEY = 'bs.quantityselector' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' +const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` +const SELECTOR_STEP_UP_BUTTON = '[data-bs-step="up"]' +const SELECTOR_STEP_DOWN_BUTTON = '[data-bs-step="down"]' +const SELECTOR_COUNTER_INPUT = '[data-bs-step="counter"]' +const SELECTOR_INPUT_GROUP = '.input-group' + +/** + * Class definition + */ + +class QuantitySelector extends BaseComponent { + // Getters + static get NAME() { + return NAME + } + + // Public + static StepUp(event) { + event.preventDefault() + const PARENT = event.target.closest(SELECTOR_INPUT_GROUP) + const COUNTER_INPUT = PARENT.querySelector(SELECTOR_COUNTER_INPUT) + + const MAX = COUNTER_INPUT.getAttribute('max') + const STEP = Number(COUNTER_INPUT.getAttribute('step')) + const ROUND = Number(COUNTER_INPUT.getAttribute('data-bs-round')) + + if (Number(COUNTER_INPUT.value) < MAX) { + COUNTER_INPUT.value = (Number(COUNTER_INPUT.value) + STEP).toFixed(ROUND).toString() + } + } + + static StepDown(event) { + event.preventDefault() + const PARENT = event.target.closest(SELECTOR_INPUT_GROUP) + const COUNTER_INPUT = PARENT.querySelector(SELECTOR_COUNTER_INPUT) + + const MIN = COUNTER_INPUT.getAttribute('min') + const STEP = Number(COUNTER_INPUT.getAttribute('step')) + const ROUND = Number(COUNTER_INPUT.getAttribute('data-bs-round')) + + if (Number(COUNTER_INPUT.value) > MIN) { + COUNTER_INPUT.value = (Number(COUNTER_INPUT.value) - STEP).toFixed(ROUND).toString() + } + } +} + +/** + * Data API implementation + */ + +EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_STEP_UP_BUTTON, QuantitySelector.StepUp) +EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_STEP_DOWN_BUTTON, QuantitySelector.StepDown) + +/** + * jQuery + */ + +defineJQueryPlugin(QuantitySelector) + +export default QuantitySelector diff --git a/js/tests/unit/quantity-selector.spec.js b/js/tests/unit/quantity-selector.spec.js new file mode 100644 index 0000000000..bc05eed6ef --- /dev/null +++ b/js/tests/unit/quantity-selector.spec.js @@ -0,0 +1,86 @@ +import QuantitySelector from '../../src/quantity-selector' +import { clearFixture, getFixture } from '../helpers/fixture' + +describe('QuantitySelector', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + describe('VERSION', () => { + it('should return plugin version', () => { + expect(QuantitySelector.VERSION).toEqual(jasmine.any(String)) + }) + }) + + describe('Default', () => { + it('should return plugin default config', () => { + expect(QuantitySelector.Default).toEqual(jasmine.any(Object)) + }) + }) + + describe('DefaultType', () => { + it('should return plugin default type config', () => { + expect(QuantitySelector.DefaultType).toEqual(jasmine.any(Object)) + }) + }) + + describe('DATA_KEY', () => { + it('should return plugin data key', () => { + expect(QuantitySelector.DATA_KEY).toEqual('bs.quantityselector') + }) + }) + + it('should take care of element either passed as a CSS selector or DOM element - Step Up button', () => { + fixtureEl.innerHTML = '' + const buttonEl = fixtureEl.querySelector('[data-bs-step="up"]') + const buttonBySelector = new QuantitySelector('[data-bs-step="up"]') + const buttonByElement = new QuantitySelector(buttonEl) + + expect(buttonBySelector._element).toEqual(buttonEl) + expect(buttonByElement._element).toEqual(buttonEl) + }) + + it('should take care of element either passed as a CSS selector or DOM element - Step Down button', () => { + fixtureEl.innerHTML = '' + const buttonEl = fixtureEl.querySelector('[data-bs-step="down"]') + const buttonBySelector = new QuantitySelector('[data-bs-step="down"]') + const buttonByElement = new QuantitySelector(buttonEl) + + expect(buttonBySelector._element).toEqual(buttonEl) + expect(buttonByElement._element).toEqual(buttonEl) + }) + + it('should increment by one step on click on StepUp button', () => { + fixtureEl.innerHTML = '' + const buttonEl = fixtureEl.querySelector('[data-bs-step="up"]') + fixtureEl.innerHTML = '' + const inputEl = fixtureEl.querySelector('[data-bs-step="counter"]') + const counterStep = inputEl.getAttribute('step') + const counterMax = inputEl.getAttribute('max') + const counterValue = inputEl.value + + buttonEl.click() + + expect(inputEl.value === counterValue + counterStep || inputEl.value === counterMax) + }) + + it('should decrement by one step on click on StepDown button', () => { + fixtureEl.innerHTML = '' + const buttonEl = fixtureEl.querySelector('[data-bs-step="down"]') + fixtureEl.innerHTML = '' + const inputEl = fixtureEl.querySelector('[data-bs-step="counter"]') + const counterStep = inputEl.getAttribute('step') + const counterMin = inputEl.getAttribute('min') + const counterValue = inputEl.value + + buttonEl.click() + + expect(inputEl.value === counterValue - counterStep || inputEl.value === counterMin) + }) +}) diff --git a/scss/_forms.scss b/scss/_forms.scss index 207aa4aff7..6b9449164c 100644 --- a/scss/_forms.scss +++ b/scss/_forms.scss @@ -7,3 +7,4 @@ // Boosted mod: no floating-labels @import "forms/input-group"; @import "forms/validation"; +@import "forms/quantity-selector"; // Boosted mod diff --git a/scss/_variables.scss b/scss/_variables.scss index 867d9e1dc0..ceeaadc829 100644 --- a/scss/_variables.scss +++ b/scss/_variables.scss @@ -360,6 +360,9 @@ $success-icon: url("data:image/svg+xml,") !default; $warning-icon: url("data:image/svg+xml,") !default; $danger-icon: url("data:image/svg+xml,") !default; +$add-icon: url("data:image/svg+xml,") !default; +$remove-icon: url("data:image/svg+xml,") !default; + //// SVG used several times $svg-as-custom-props: ( "chevron": $chevron-icon, @@ -368,6 +371,7 @@ $svg-as-custom-props: ( "success": $success-icon, "error": $danger-icon ) !default; + //// Filters // see https://codepen.io/sosuke/pen/Pjoqqp $invert-filter: invert(1) !default; @@ -971,6 +975,8 @@ $input-line-height-lg: $h5-line-height !default; // Boosted mod $input-transition: border-color $transition-duration $transition-timing, $transition-focus !default; $form-color-width: 3rem !default; +// End mod + // scss-docs-end form-input-variables // scss-docs-start form-check-variables @@ -1838,4 +1844,22 @@ $step-link-marker-lg: counter($stepped-process-counter) ".\A0" !default; $step-item-arrow-width: 1rem !default; $step-item-arrow-shape: polygon(0% 0%, subtract(100%, $border-width) 50%, 0% 100%) #{"/* rtl:"} polygon(100% 0%, $border-width 50%, 100% 100%) #{"*/"} !default; // Used in clip-path // scss-docs-end stepped-process + +//// Quantity selector +// scss-docs-start quantity-selector +$quantity-selector-icon-add: $add-icon !default; +$quantity-selector-icon-remove: $remove-icon !default; + +$quantity-selector-sm-width: 5.625rem !default; +$quantity-selector-input-width: 2.75rem !default; +$quantity-selector-input-sm-width: 2.125rem !default; + +$quantity-selector-icon-width: .875rem !default; +$quantity-selector-icon-remove-height: .125rem !default; +$quantity-selector-icon-add-height: .875rem !default; + +$quantity-selector-icon-sm-width: .625rem !default; +$quantity-selector-icon-sm-remove-height: .125rem !default; +$quantity-selector-icon-sm-add-height: .625rem !default; +// scss-docs-end quantity-selector // End mod diff --git a/scss/forms/_quantity-selector.scss b/scss/forms/_quantity-selector.scss new file mode 100644 index 0000000000..a973e009bf --- /dev/null +++ b/scss/forms/_quantity-selector.scss @@ -0,0 +1,64 @@ +.quantity-selector { + width: 7.5em; + $validation-messages: ""; + + @each $state in map-keys($form-validation-states) { + $validation-messages: $validation-messages + ":not(." + unquote($state) + "-tooltip)" + ":not(." + unquote($state) + "-feedback)"; + } + + > :not(:first-child):not(.dropdown-menu)#{$validation-messages} { + margin-left: 0; + } + + .form-control { + max-width: $quantity-selector-input-width; + padding: 0; + font-size: $font-size-sm; + text-align: center; + transition: none; // stylelint-disable-line property-disallowed-list + appearance: textfield; + + &:not(:focus) { + border-right: none; + border-left: none; + } + + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + margin: 0; + appearance: none; + } + } + + button { + border: $border-width solid $gray-500; + + &:first-of-type { + @include button-icon($quantity-selector-icon-remove, $size: $quantity-selector-icon-width $quantity-selector-icon-remove-height, $pseudo: "after"); + order: -1; + border-right: none; + + &.btn-sm { // stylelint-disable-line selector-no-qualifying-type + @include button-icon($quantity-selector-icon-remove, $width: 1rem, $height: 1rem, $size: $quantity-selector-icon-sm-width $quantity-selector-icon-sm-remove-height, $pseudo: "after"); + } + } + + &:last-of-type { + @include button-icon($quantity-selector-icon-add, $size: $quantity-selector-icon-width $quantity-selector-icon-add-height, $pseudo: "after"); + border-left: none; + + &.btn-sm { // stylelint-disable-line selector-no-qualifying-type + @include button-icon($quantity-selector-icon-add, $width: 1rem, $height: 1rem, $size: $quantity-selector-icon-sm-width $quantity-selector-icon-sm-add-height, $pseudo: "after"); + } + } + } +} + +.quantity-selector-sm { + width: $quantity-selector-sm-width; + + .form-control { + max-width: $quantity-selector-input-sm-width; + font-size: $font-size-base; + } +} diff --git a/scss/mixins/_forms.scss b/scss/mixins/_forms.scss index 0d119ddf1b..18e9ae03d2 100644 --- a/scss/mixins/_forms.scss +++ b/scss/mixins/_forms.scss @@ -75,6 +75,20 @@ } } + // Boosted mod: Remove border on input for form element QuantitySelector + .quantity-selector { + .form-control { + @include form-validation-state-selector($state) { + border-right: none; + border-left: none; + & ~ button { + border-color: $color; + } + } + } + } + // End mod + // Boosted mod: no icon in background for textarea .form-select { diff --git a/site/content/docs/5.1/about/overview.md b/site/content/docs/5.1/about/overview.md index 8fb22ff577..60d3857986 100644 --- a/site/content/docs/5.1/about/overview.md +++ b/site/content/docs/5.1/about/overview.md @@ -24,6 +24,7 @@ Boosted ships with custom accessible components to suit specific needs: - [Back to top]({{< docsref "/components/back-to-top" >}}) - [Orange Navbars]({{< docsref "/components/orange-navbar" >}}) +- [Quantity selector]({{< docsref "/forms/quantity-selector" >}}) - [Stepped process]({{< docsref "/components/stepped-process" >}}) diff --git a/site/content/docs/5.1/customize/overview.md b/site/content/docs/5.1/customize/overview.md index 0782b2c590..3c93a27570 100644 --- a/site/content/docs/5.1/customize/overview.md +++ b/site/content/docs/5.1/customize/overview.md @@ -50,5 +50,6 @@ Several Boosted components include embedded SVGs in our CSS to style components - [Carousel controls]({{< docsref "/components/carousel#with-controls" >}}) - [Navbar toggle buttons]({{< docsref "/components/navbar#responsive-behaviors" >}}) - [Pagination]({{< docsref "/components/pagination" >}}) +- [Quantity selector buttons]({{< docsref "/forms/quantity-selector" >}}) Based on [community conversation](https://github.com/twbs/bootstrap/issues/25394), some options for addressing this in your own codebase include replacing the URLs with locally hosted assets, removing the images and using inline images (not possible in all components), and modifying your CSP. Our recommendation is to carefully review your own security policies and decide on the best path forward, if necessary. diff --git a/site/content/docs/5.1/forms/quantity-selector.md b/site/content/docs/5.1/forms/quantity-selector.md new file mode 100644 index 0000000000..978f3fbebf --- /dev/null +++ b/site/content/docs/5.1/forms/quantity-selector.md @@ -0,0 +1,80 @@ +--- +layout: docs +title: Quantity selector +description: Form element used to select a number. +group: forms +toc: true +--- + +## Examples + +Quantity selector is a form element used to select a number. The default version is the large version. To use the small version, use the contextual class `.quantity-selector-sm`. + +You can specify a default value in the `value` attribute of your input. + +Value will vary between the values define in the `min` and `max` attributes (negatives values are allowed). + +The custom `data-bs-round` attribute will help you to define the number of digits to appear after the decimal point. + +{{< example >}} +
+
+ +
+ + + +
+
Lorem ipsum.
+
+
+ +
+ + + +
+
Lorem ipsum.
+
+ +
+{{< /example >}} + +## Disabled + +Add the `disabled` boolean attribute on a select to give it a grayed out appearance and remove pointer events. + +{{< example >}} +
+
+ +
+ + + +
+
Lorem ipsum.
+
+ +
+{{< /example >}} + +## Sass + +### Variables + +For more details, please have a look at the exhaustive list of available variables: + +{{< scss-docs name="quantity-selector" file="scss/_variables.scss" >}} \ No newline at end of file diff --git a/site/content/docs/5.1/forms/validation.md b/site/content/docs/5.1/forms/validation.md index ec7f9859b4..a5a3ebf090 100644 --- a/site/content/docs/5.1/forms/validation.md +++ b/site/content/docs/5.1/forms/validation.md @@ -283,6 +283,20 @@ Validation styles are available for the following form controls and components:
Example invalid form file feedback
+
+ +
+ + + +
Please enter a valid number.
+
+
+
diff --git a/site/content/docs/5.1/getting-started/introduction.md b/site/content/docs/5.1/getting-started/introduction.md index 12f540090e..1be3ea0c9f 100644 --- a/site/content/docs/5.1/getting-started/introduction.md +++ b/site/content/docs/5.1/getting-started/introduction.md @@ -66,6 +66,7 @@ Curious which components explicitly require our JavaScript and Popper? Click the - Modals for displaying, positioning, and scroll behavior - Navbar for extending our Collapse plugin to implement responsive behavior - Offcanvases for displaying, positioning, and scroll behavior +- Quantity selector for incrementing/decrementing number value - Toasts for displaying and dismissing - Tooltips and popovers for displaying and positioning (also requires [Popper](https://popper.js.org/)) - Scrollspy for scroll behavior and navigation updates diff --git a/site/data/sidebar.yml b/site/data/sidebar.yml index 5a878505d2..e59483d5b0 100644 --- a/site/data/sidebar.yml +++ b/site/data/sidebar.yml @@ -52,6 +52,7 @@ - title: Checks & radios - title: Range - title: Input group + - title: Quantity selector - title: Layout - title: Validation