UNPKG

@lion/ui

Version:

A package of extendable web components

458 lines (413 loc) 12.6 kB
import { html, css, render, nothing } from 'lit'; import { formatNumber, LocalizeMixin, parseNumber } from '@lion/ui/localize-no-side-effects.js'; import { LionInput } from '@lion/ui/input.js'; import { IsNumber, MinNumber, MaxNumber } from '@lion/ui/form-core.js'; import { localizeNamespaceLoader } from './localizeNamespaceLoader.js'; /** * @typedef {import('lit').RenderOptions} RenderOptions */ /** * `LionInputStepper` is a class for custom input-stepper element (`<lion-input-stepper>` web component). * * @customElement lion-input-stepper */ export class LionInputStepper extends LocalizeMixin(LionInput) { static get styles() { return [ ...super.styles, css` .input-group__container > .input-group__input ::slotted(.form-control) { text-align: center; } .input-stepper__value { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(100%); clip: rect(1px, 1px, 1px, 1px); white-space: nowrap; border: 0; margin: 0; padding: 0; } `, ]; } /** @type {any} */ static get properties() { return { min: { type: Number, reflect: true, }, max: { type: Number, reflect: true, }, valueTextMapping: { type: Object, }, step: { type: Number, reflect: true, }, }; } static localizeNamespaces = [ { 'lion-input-stepper': localizeNamespaceLoader }, ...super.localizeNamespaces, ]; /** * @returns {number} */ get currentValue() { return this.modelValue || 0; } get _inputNode() { return /** @type {HTMLInputElement} */ (super._inputNode); } constructor() { super(); /** @param {string} modelValue */ this.parser = parseNumber; this.formatter = formatNumber; this.min = Infinity; this.max = Infinity; /** * The aria-valuetext attribute defines the human-readable text alternative of aria-valuenow. * @type {{[key: number]: string}} */ this.valueTextMapping = {}; 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); } connectedCallback() { super.connectedCallback(); this.values = { max: this.max, min: this.min, step: this.step, }; if (this._inputNode) { this._inputNode.role = 'spinbutton'; this._inputNode.setAttribute('inputmode', 'decimal'); this._inputNode.setAttribute('autocomplete', 'off'); } this.addEventListener('keydown', this.__keyDownHandler); this.__setDefaultValidators(); this.__toggleSpinnerButtonsState(); } disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener('keydown', this.__keyDownHandler); } /** @param {import('lit').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; if (this.min !== Infinity) { this._inputNode.setAttribute('aria-valuemin', `${this.min}`); } else { this._inputNode.removeAttribute('aria-valuemin'); } this.__toggleSpinnerButtonsState(); } if (changedProperties.has('max')) { this._inputNode.max = `${this.max}`; this.values.max = this.max; if (this.max !== Infinity) { this._inputNode.setAttribute('aria-valuemax', `${this.max}`); } else { this._inputNode.removeAttribute('aria-valuemax'); } this.__toggleSpinnerButtonsState(); } if (changedProperties.has('valueTextMapping')) { this._updateAriaAttributes(); } 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 */ __setDefaultValidators() { const validators = /** @type {(IsNumber| MaxNumber | MinNumber)[]} */ ( [ new IsNumber(), this.min !== Infinity ? new MinNumber(this.min) : null, this.max !== Infinity ? new MaxNumber(this.max) : null, ].filter(validator => validator !== null) ); this.defaultValidators.push(...validators); } /** * Update values on keyboard arrow up and down event * @param {KeyboardEvent} ev - keyboard event * @private */ __keyDownHandler(ev) { if (ev.key === 'ArrowUp') { this._increment(); } if (ev.key === 'ArrowDown') { this._decrement(); } } /** * Toggle disabled state for the buttons * @private */ __toggleSpinnerButtonsState() { const { min, max } = this.values; const decrementButton = /** @type {HTMLButtonElement} */ (this.__getSlot('prefix')); const incrementButton = /** @type {HTMLButtonElement} */ (this.__getSlot('suffix')); const disableIncrementor = this.currentValue >= max && max !== Infinity; const disableDecrementor = this.currentValue <= min && min !== Infinity; if ( (disableDecrementor && decrementButton === document.activeElement) || (disableIncrementor && incrementButton === document.activeElement) ) { this._inputNode.focus(); } decrementButton[disableDecrementor ? 'setAttribute' : 'removeAttribute']('disabled', 'true'); incrementButton[disableIncrementor ? 'setAttribute' : 'removeAttribute']('disabled', 'true'); this._updateAriaAttributes(); } /** * @protected */ _updateAriaAttributes() { const displayValue = this._inputNode.value; if (displayValue) { this._inputNode.setAttribute('aria-valuenow', `${displayValue}`); if ( Object.keys(this.valueTextMapping).length !== 0 && Object.keys(this.valueTextMapping).find(key => Number(key) === this.currentValue) ) { this.__valueText = this.valueTextMapping[this.currentValue]; } else { // VoiceOver announces percentages once the valuemin or valuemax are used. // This can be fixed by setting valuetext to the same value as valuenow // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-valuenow this.__valueText = displayValue; } this._inputNode.setAttribute('aria-valuetext', `${this.__valueText}`); } else { this._inputNode.removeAttribute('aria-valuenow'); this._inputNode.removeAttribute('aria-valuetext'); } this._destroyOutputContent(); } _destroyOutputContent() { const outputElement = /** @type {HTMLElement} */ ( this.shadowRoot?.querySelector('.input-stepper__value') ); const timeoutValue = outputElement?.dataset?.selfDestruct ? Number(outputElement.dataset.selfDestruct) : 2000; clearTimeout(this.timer); if (outputElement) { this.timer = setTimeout(() => { if (outputElement.parentNode) { this.__valueText = nothing; this.requestUpdate(); } }, timeoutValue); } } /** * 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 * @protected */ _increment() { const { step, min, max } = this.values; const stepMin = min !== Infinity ? min : 0; const epsilon = 1e-10; // Tolerance for floating-point comparison let newValue; const remainder = (this.currentValue - stepMin) % step; const isAligned = Math.abs(remainder) < epsilon || Math.abs(remainder - step) < epsilon; if (!isAligned) { // If the value is not aligned to step, align it to the next valid step newValue = Math.ceil((this.currentValue - stepMin) / step) * step + stepMin; } else { // If the value is aligned, just add the step newValue = this.currentValue + step; } if (newValue <= max || max === Infinity) { this.modelValue = newValue < min && min !== Infinity ? `${min}` : `${newValue}`; this.__toggleSpinnerButtonsState(); this._proxyInputEvent(); } } /** * Decrement the value based on given step or default step value is 1 * @protected */ _decrement() { const { step, max, min } = this.values; const stepMin = min !== Infinity ? min : 0; const epsilon = 1e-10; // Tolerance for floating-point comparison let newValue; const remainder = (this.currentValue - stepMin) % step; const isAligned = Math.abs(remainder) < epsilon || Math.abs(remainder - step) < epsilon; if (!isAligned) { // If the value is not aligned to step, align it to the previous valid step newValue = Math.floor((this.currentValue - stepMin) / step) * step + stepMin; } else { // If the value is aligned, just subtract the step newValue = this.currentValue - step; } if (newValue >= min || min === Infinity) { this.modelValue = newValue > max && max !== Infinity ? `${max}` : `${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('lit').TemplateResult} * @protected */ // eslint-disable-next-line class-methods-use-this _decrementorSignTemplate() { return '-'; } /** * Get the incrementor button sign template * @returns {String|import('lit').TemplateResult} * @protected */ // eslint-disable-next-line class-methods-use-this _incrementorSignTemplate() { return '+'; } /** * Get the increment button template * @returns {import('lit').TemplateResult} * @protected */ _decrementorTemplate() { return html` <button ?disabled=${this.disabled || this.readOnly} @click=${this._decrement} type="button" aria-label="${this.msgLit('lion-input-stepper:decrease')} ${this.fieldName}" > ${this._decrementorSignTemplate()} </button> `; } /** * Get the decrement button template * @returns {import('lit').TemplateResult} * @protected */ _incrementorTemplate() { return html` <button ?disabled=${this.disabled || this.readOnly} @click=${this._increment} type="button" aria-label="${this.msgLit('lion-input-stepper:increase')} ${this.fieldName}" > ${this._incrementorSignTemplate()} </button> `; } /** @protected */ _inputGroupTemplate() { return html` <output for="${this._inputId}" data-self-destruct="2000" class="input-stepper__value" >${this.__valueText}</output > <div class="input-group"> ${this._inputGroupBeforeTemplate()} <div class="input-group__container"> ${this._inputGroupPrefixTemplate()} ${this._inputGroupInputTemplate()} ${this._inputGroupSuffixTemplate()} </div> ${this._inputGroupAfterTemplate()} </div> `; } }