import { html, css, render } from 'lit'; import { LionInput } from '@lion/ui/input.js'; import { IsNumber, MinNumber, MaxNumber } from '@lion/ui/form-core.js'; /** * @typedef {import('@lion/core').RenderOptions} RenderOptions */ /** * `LionInputStepper` is a class for custom input-stepper element (`` web component). * * @customElement lion-input-stepper */ export class LionInputStepper extends LionInput { static get styles() { return [ ...super.styles, css` .input-group__container > .input-group__input ::slotted(.form-control) { text-align: center; } `, ]; } /** @type {any} */ static get properties() { return { min: { type: Number, reflect: true, }, max: { type: Number, reflect: true, }, step: { type: Number, reflect: true, }, }; } /** * @returns {number} */ get currentValue() { return parseFloat(this.value) || 0; } get _inputNode() { return /** @type {HTMLInputElement} */ (super._inputNode); } constructor() { super(); /** @param {string} modelValue */ this.parser = modelValue => parseFloat(modelValue); this.min = Infinity; this.max = Infinity; this.step = 1; this.values = { max: this.max, min: this.min, step: this.step, }; this.__increment = this.__increment.bind(this); this.__decrement = this.__decrement.bind(this); this.__boundOnLeaveButton = this._onLeaveButton.bind(this); } connectedCallback() { super.connectedCallback(); this.values = { max: this.max, min: this.min, step: this.step, }; this.role = 'spinbutton'; this.addEventListener('keydown', this.__keyDownHandler); this._inputNode.setAttribute('inputmode', 'decimal'); this._inputNode.setAttribute('autocomplete', 'off'); this.setAttribute('aria-label', this.label); this.step = this.hasAttribute('step') ? this.step : 1; this.__setAriaLabelsAndValidator(); this.__toggleSpinnerButtonsState(); } disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener('keydown', this.__keyDownHandler); } /** @param {import('@lion/core').PropertyValues } changedProperties */ updated(changedProperties) { super.updated(changedProperties); if (changedProperties.has('modelValue')) { this.__toggleSpinnerButtonsState(); } if (changedProperties.has('min')) { this._inputNode.min = `${this.min}`; this.values.min = this.min; this.__toggleSpinnerButtonsState(); } if (changedProperties.has('max')) { this._inputNode.max = `${this.max}`; this.values.max = this.max; this.__toggleSpinnerButtonsState(); } if (changedProperties.has('step')) { this._inputNode.step = `${this.step}`; this.values.step = this.step; } } get slots() { return { ...super.slots, prefix: () => this.__getDecrementButtonNode(), suffix: () => this.__getIncrementButtonNode(), }; } /** * Set aria labels and apply validators * @private */ __setAriaLabelsAndValidator() { const ariaAttributes = { 'aria-valuemax': this.values.max, 'aria-valuemin': this.values.min, }; const minMaxValidators = /** @type {(MaxNumber | MinNumber)[]} */ ( Object.entries(ariaAttributes) .map(([key, val]) => { if (val !== Infinity) { this.setAttribute(key, `${val}`); return key === 'aria-valuemax' ? new MaxNumber(val) : new MinNumber(val); } return null; }) .filter(validator => validator !== null) ); const validators = [new IsNumber(), ...minMaxValidators]; this.defaultValidators.push(...validators); } /** * Update values on keyboard arrow up and down event * @param {KeyboardEvent} e - keyboard event * @private */ __keyDownHandler(e) { if (e.key === 'ArrowUp') { this.__increment(); } if (e.key === 'ArrowDown') { this.__decrement(); } } /** * Toggle disabled state for the buttons * @private */ __toggleSpinnerButtonsState() { const { min, max } = this.values; const decrementButton = this.__getSlot('prefix'); const incrementButton = this.__getSlot('suffix'); const disableIncrementor = this.currentValue >= max && max !== Infinity; const disableDecrementor = this.currentValue <= min && min !== Infinity; decrementButton[disableDecrementor ? 'setAttribute' : 'removeAttribute']('disabled', 'true'); incrementButton[disableIncrementor ? 'setAttribute' : 'removeAttribute']('disabled', 'true'); this.setAttribute('aria-valuenow', `${this.currentValue}`); } /** * Get slotted element * @param {String} slotName - slot name * @returns {HTMLButtonElement|Object} * @private */ __getSlot(slotName) { return ( /** @type {HTMLElement[]} */ (Array.from(this.children)).find( child => child.slot === slotName, ) || {} ); } /** * Increment the value based on given step or default step value is 1 * @private */ __increment() { const { step, max } = this.values; const newValue = this.currentValue + step; if (newValue <= max || max === Infinity) { this.value = `${newValue}`; this.__toggleSpinnerButtonsState(); this._proxyInputEvent(); } } /** * Decrement the value based on given step or default step value is 1 * @private */ __decrement() { const { step, min } = this.values; const newValue = this.currentValue - step; if (newValue >= min || min === Infinity) { this.value = `${newValue}`; this.__toggleSpinnerButtonsState(); this._proxyInputEvent(); } } /** * Get the increment button node * @returns {Element|null} * @private */ __getIncrementButtonNode() { const renderParent = document.createElement('div'); render( this._incrementorTemplate(), renderParent, /** @type {RenderOptions} */ ({ scopeName: this.localName, eventContext: this, }), ); return renderParent.firstElementChild; } /** * Get the decrement button node * @returns {Element|null} * @private */ __getDecrementButtonNode() { const renderParent = document.createElement('div'); render( this._decrementorTemplate(), renderParent, /** @type {RenderOptions} */ ({ scopeName: this.localName, eventContext: this, }), ); return renderParent.firstElementChild; } /** * Toggle +/- buttons on change * @override * @protected */ _onChange() { super._onChange(); this.__toggleSpinnerButtonsState(); } /** * Get the decrementor button sign template * @returns {String|import('@lion/core').TemplateResult} * @protected */ // eslint-disable-next-line class-methods-use-this _decrementorSignTemplate() { return '-'; } /** * Get the incrementor button sign template * @returns {String|import('@lion/core').TemplateResult} * @protected */ // eslint-disable-next-line class-methods-use-this _incrementorSignTemplate() { return '+'; } /** * Get the increment button template * @returns {import('@lion/core').TemplateResult} * @protected */ _decrementorTemplate() { return html` `; } /** * Get the decrement button template * @returns {import('@lion/core').TemplateResult} * @protected */ _incrementorTemplate() { return html` `; } /** * Redispatch leave event on host when catching leave event * on the incrementor and decrementor button. * * This redispatched leave event will be caught by * InteractionStateMixin to set "touched" state to true. * * Interacting with the buttons is "user interactions" * the same way as focusing + blurring the field (native input) */ _onLeaveButton() { this.dispatchEvent(new Event(this._leaveEvent)); } }