@lion/ui
Version:
A package of extendable web components
458 lines (413 loc) • 12.6 kB
JavaScript
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}
=${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}
=${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>
`;
}
}