lion/packages/ui/components/input-stepper/src/LionInputStepper.js

461 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { html, css, render } 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);
this._onEnterButton = this._onEnterButton.bind(this);
this._onLeaveButton = this._onLeaveButton.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');
}
}
/**
* 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, min, max } = this.values;
const stepMin = min !== Infinity ? min : 0;
let newValue = this.currentValue + step;
if ((this.currentValue + stepMin) % step !== 0) {
// If the value is not aligned to step, align it to the nearest step
newValue = Math.floor(this.currentValue / step) * step + step + (stepMin % 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
* @private
*/
__decrement() {
const { step, max, min } = this.values;
const stepMin = min !== Infinity ? min : 0;
let newValue = this.currentValue - step;
if ((this.currentValue + stepMin) % step !== 0) {
// If the value is not aligned to step, align it to the nearest step
newValue = Math.floor(this.currentValue / step) * step + (stepMin % 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}
@focus=${this._onEnterButton}
@blur=${this._onLeaveButton}
type="button"
aria-label="${this.msgLit('lion-input-stepper:decrease')}"
>
${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}
@focus=${this._onEnterButton}
@blur=${this._onLeaveButton}
type="button"
aria-label="${this.msgLit('lion-input-stepper:increase')}"
>
${this._incrementorSignTemplate()}
</button>
`;
}
/** @protected */
_inputGroupTemplate() {
return html`
<div class="input-stepper__value">${this.__valueText}</div>
<div class="input-group">
${this._inputGroupBeforeTemplate()}
<div class="input-group__container">
${this._inputGroupPrefixTemplate()} ${this._inputGroupInputTemplate()}
${this._inputGroupSuffixTemplate()}
</div>
${this._inputGroupAfterTemplate()}
</div>
`;
}
/**
* @protected
* @param {Event} ev
*/
// eslint-disable-next-line no-unused-vars
_onEnterButton(ev) {
const valueNode = /** @type {HTMLElement} */ (
this.shadowRoot?.querySelector('.input-stepper__value')
);
valueNode.setAttribute('aria-live', 'assertive');
}
/**
* 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)
*
* @protected
* @param {Event} ev
*/
// eslint-disable-next-line no-unused-vars
_onLeaveButton(ev) {
const valueNode = /** @type {HTMLElement} */ (
this.shadowRoot?.querySelector('.input-stepper__value')
);
valueNode.removeAttribute('aria-live');
this.dispatchEvent(new Event(this._leaveEvent));
}
}