UNPKG

@grail-ui/svelte

Version:

[![NPM](https://img.shields.io/npm/v/@grail-ui/svelte)](https://www.npmjs.com/package/@grail-ui/svelte) [![minified](https://img.shields.io/bundlephobia/min/@grail-ui/svelte)](https://bundlephobia.com/package/@grail-ui/svelte) [![minified + zipped](https:

275 lines (274 loc) 10.8 kB
import { derived, get, readable, writable } from 'svelte/store'; import { createKeyStroke } from '../keyStroke/keyStroke.js'; import { coerceNumber } from '../coercion/number.js'; import { addEventListener } from '../eventListener/eventListener.js'; import { chain } from '../util/chain.js'; import { noop } from '../util/noop.js'; import { uniqueId } from '../util/id.js'; import { isClient } from '../util/is.js'; import { snapValueToStep } from '../util/number.js'; import { observeElement } from './numberInput.utils.js'; const defaultReg = /^[0-9+\-.]$/; const defaultPasteReg = /^-?(0|[1-9]\d*)(\.\d+)?$/; export const createNumberInput = (initConfig = {}) => { const id = uniqueId('number-input'); const { clampValueOnBlur = true, allowMouseWheel = true, inputMode = 'decimal', spinOnPress = true, pattern = '[0-9]*(.[0-9]+)?', incrementAriaLabel = 'Increment', decrementAriaLabel = 'Decrement', } = initConfig; const inputValueChange = writable(0); const min$ = writable(initConfig.min); const max$ = writable(initConfig.max); const step = writable(initConfig.step ?? 1); const step$ = derived(step, ($step) => { const step = coerceNumber($step, 1); return step > 0 ? step : 1; }); const getInputEl = () => isClient ? document.getElementById(id) : null; const setValue = (value) => { const input = getInputEl(); if (!input) return; input.value = `${value}`; input.dispatchEvent(new Event('input', { bubbles: true })); }; const getValue = () => { const input = getInputEl(); if (!input) return null; else return input.value; }; const characterValidation = (event) => { const character = event.key; const value = getValue(); if (!defaultReg.test(character)) { return false; } if (character === '-' && (value?.includes('-') || (event?.target).selectionStart !== 0)) { return false; } if (character === '+' && (value?.includes('+') || (event?.target).selectionStart !== 0)) { return false; } return true; }; const safeNextStep = (multiplier, steps) => { const value = get(valueAsNumber) ?? 0; const min = get(min$); const max = get(max$); const step = get(step$); // First snap the current value to the nearest step. If it moves in the direction // we're going, use it as the first step. const newValue = snapValueToStep(value, min, max, step); if ((multiplier > 0 && newValue > value) || (multiplier < 0 && newValue < value)) { steps = steps - 1; } return steps === 0 ? newValue : snapValueToStep(newValue + multiplier * step * steps, min, max, step); }; const inputValueHandler = (type, event) => { setValue(safeNextStep(type === '+' ? 1 : -1, event?.shiftKey ? 10 : 1)); }; const handleFocus = (event) => { if (document?.activeElement === getInputEl()) { return; } if (event?.pointerType === 'mouse') { getInputEl()?.focus(); } else { event.target?.focus(); } }; const increment = () => inputValueHandler('+'); const decrement = () => inputValueHandler('-'); const useNumberInput = (node) => { const removeFocusoutEvent = addEventListener(node, 'focusout', (e) => { if (e.target === getInputEl() && clampValueOnBlur) { const $value = get(valueAsNumber); if ($value != null) { setValue(snapValueToStep($value, get(min$), get(max$), get(step$))); } } }); const removeInputEvent = addEventListener(node, 'input', (e) => { const input = getInputEl(); if (e.target === input) { inputValueChange.update((value) => ++value); } }); let removePointerDownEvent = noop; let removePointerUpEvent = noop; let interval; let timeout; function getModeButton(e) { const target = e.target; if (!target || !(target instanceof HTMLElement)) return; if (target.dataset.mode) return target.dataset.mode; const button = [...document.querySelectorAll(`[aria-controls="${id}"]`)].find((b) => b.contains(target)); if (button && button instanceof HTMLElement) { return button.dataset.mode; } } removePointerDownEvent = addEventListener(node, 'pointerdown', (e) => { const mode = getModeButton(e); if (!mode) return; const handleAction = mode === '+' ? increment : decrement; e.preventDefault(); handleAction(); handleFocus(e); if (spinOnPress) { timeout = setTimeout(() => { interval = setInterval(handleAction, 60); }, 400); removePointerUpEvent = addEventListener(window.document, 'pointerup', () => { if (timeout) clearTimeout(timeout); if (interval) clearInterval(interval); removePointerUpEvent(); }); } }); const keypressHandler = (e) => { if (e.target === getInputEl()) { if (!characterValidation(e)) e.preventDefault(); } }; const removeKeypressEvent = createKeyStroke({ key: true, target: node, handler: keypressHandler, eventName: 'keypress', }); const pasteHandler = (e) => { if (e.target === getInputEl()) { const clipboardData = e.clipboardData; const pastedData = clipboardData?.getData('text'); if (pastedData && !defaultPasteReg.test(pastedData)) { e.stopPropagation(); e.preventDefault(); } } }; const removePasteEvent = addEventListener(node, 'paste', pasteHandler); let removeWheelEvent = noop; if (allowMouseWheel) { const wheelHandler = (e) => { const input = getInputEl(); if (e.target === input && document?.activeElement === input) { e.preventDefault(); if (e.deltaY < 0) increment(); else decrement(); } }; removeWheelEvent = addEventListener(node, 'wheel', wheelHandler, { passive: false }); } const removeIncButtonEvent = createKeyStroke({ key: ['ArrowUp', 'PageUp', 'Shift+ArrowUp', 'Shift+PageUp'], handler: (e) => { if (e.target === getInputEl()) { e.preventDefault(); inputValueHandler('+', e); } }, target: node, }); const removeDecButtonEvent = createKeyStroke({ key: ['ArrowDown', 'PageDown', 'Shift+ArrowDown', 'Shift+PageDown'], handler: (e) => { if (e.target === getInputEl()) { e.preventDefault(); inputValueHandler('-', e); } }, target: node, }); const removeHomeButtonEvent = createKeyStroke({ key: 'Home', handler: (e) => { if (e.target === getInputEl()) { e.preventDefault(); const min = get(min$); if (min != null) { setValue(min); } } }, target: node, }); const removeEndButtonEvent = createKeyStroke({ key: 'End', handler: (e) => { if (e.target === getInputEl()) { e.preventDefault(); const max = get(max$); if (max != null) { setValue(snapValueToStep(max, get(min$), max, get(step$))); } } }, target: node, }); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion observeElement(getInputEl(), 'value', function () { inputValueChange.update((value) => ++value); }); return { destroy() { chain(removeFocusoutEvent, removeInputEvent, removePointerDownEvent, removeKeypressEvent, removePasteEvent, removeWheelEvent, removeIncButtonEvent, removeDecButtonEvent, removeHomeButtonEvent, removeEndButtonEvent); }, }; }; const labelAttrs = readable({ for: id }); const inputAttrs = derived([min$, max$], ([$min, $max]) => ({ inputmode: inputMode, id, pattern, autocorrect: 'off', autocomplete: 'off', spellcheck: 'false', 'aria-roledescription': 'numberfield', 'aria-valuemin': $min == null ? null : `${$min}`, 'aria-valuemax': $max == null ? null : `${$max}`, })); const valueAsNumber = derived(inputValueChange, () => coerceNumber(getValue())); const canDecrement = derived([valueAsNumber, min$], ([$value, $min]) => $value == null || $min == null || $value > $min); const canIncrement = derived([valueAsNumber, min$, max$, step$], ([$value, $min, $max, $step]) => { return $value == null || $max == null || $value < snapValueToStep($max, $min, $max, $step); }); const incrementAttrs = derived([canIncrement], ([$canIncrement]) => ({ 'aria-controls': id, tabindex: '-1', 'data-mode': '+', 'aria-label': incrementAriaLabel, disabled: !$canIncrement, })); const decrementAttrs = derived([canDecrement], ([$canDecrement]) => ({ 'aria-controls': id, tabindex: '-1', 'data-mode': '-', 'aria-label': decrementAriaLabel, disabled: !$canDecrement, })); const invalid = derived([valueAsNumber, min$, max$, step$, step], ([$value, $min, $max, $step$, $step]) => $step !== 'any' && $value != null && $value !== snapValueToStep($value, $min, $max, $step$)); return { useNumberInput, labelAttrs, inputAttrs, incrementAttrs, decrementAttrs, min: min$, max: max$, step, invalid, valueAsNumber, }; };