diff --git a/README.md b/README.md index a033019..6f195ff 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ import TimeField from 'react-simple-timefield'; inputRef={(ref) => {...}} // {Function} input's ref colon=":" // {String} default: ":" showSeconds // {Boolean} default: false + disableHoursLimit // {Boolean} default: false + maxHoursLength={3} // {Number} default: 2, should be equal or greater than 2 /> ``` diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..ed81c09 --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,35 @@ +import React, { ChangeEvent, CSSProperties, ReactElement } from 'react'; +export declare function isNumber(value: T): boolean; +export declare function formatTimeItem(value?: string | number, len?: number): string; +export declare function validateTimeAndCursor(showSeconds?: boolean, value?: string, defaultValue?: string, colon?: string, cursorPosition?: number, disableHoursLimit?: boolean, maxHoursLength?: number): [string, number]; +declare type onChangeType = (event: ChangeEvent, value: string) => void; +interface Props { + value?: string; + onChange?: onChangeType; + showSeconds?: boolean; + input: ReactElement | null; + inputRef?: () => HTMLInputElement | null; + colon?: string; + style?: CSSProperties | {}; + disableHoursLimit?: boolean; + maxHoursLength?: number; +} +interface State { + value: string; + _colon: string; + _defaultValue: string; + _showSeconds: boolean; + _maxLength: number; + _disableHoursLimit: boolean; + _maxHoursLength: number; +} +export default class TimeField extends React.Component { + private numberPositions; + private colonPositions; + static defaultProps: Props; + constructor(props: Props); + componentDidUpdate(prevProps: Props): void; + onInputChange(event: ChangeEvent, callback: onChangeType): void; + render(): ReactElement; +} +export {}; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..1f2bf2b --- /dev/null +++ b/dist/index.js @@ -0,0 +1,249 @@ +"use strict"; +var __extends = (this && this.__extends) || (function () { + var extendStatics = function (d, b) { + extendStatics = Object.setPrototypeOf || + ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || + function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; }; + return extendStatics(d, b); + }; + return function (d, b) { + extendStatics(d, b); + function __() { this.constructor = d; } + d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); + }; +})(); +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +var __rest = (this && this.__rest) || function (s, e) { + var t = {}; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) + t[p] = s[p]; + if (s != null && typeof Object.getOwnPropertySymbols === "function") + for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { + if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) + t[p[i]] = s[p[i]]; + } + return t; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +var react_1 = __importDefault(require("react")); +var DEFAULT_COLON = ':'; +var DEFAULT_VALUE_SHORT = "00" + DEFAULT_COLON + "00"; +var DEFAULT_VALUE_FULL = "00" + DEFAULT_COLON + "00" + DEFAULT_COLON + "00"; +var DEFAULT_HOURS_LENGTH = 2; +var DEFAULT_VALUE_HOURS_LEN = function (len) { + if (len === void 0) { len = DEFAULT_HOURS_LENGTH; } + return "" + '0'.repeat(len) + DEFAULT_COLON + "00" + DEFAULT_COLON + "00"; +}; +var DEFAULT_COLON_POSITIONS = [3, 6]; +var DEFAULT_NUMBER_POSITIONS = [2, 5]; +function isNumber(value) { + var number = Number(value); + return !isNaN(number) && String(value) === String(number); +} +exports.isNumber = isNumber; +function formatTimeItem(value, len) { + if (len === void 0) { len = DEFAULT_HOURS_LENGTH; } + var zeros = '0'.repeat(len); + return ("" + (value || '') + zeros).substr(0, len); +} +exports.formatTimeItem = formatTimeItem; +function validateTimeAndCursor(showSeconds, value, defaultValue, colon, cursorPosition, disableHoursLimit, maxHoursLength) { + if (showSeconds === void 0) { showSeconds = false; } + if (value === void 0) { value = ''; } + if (defaultValue === void 0) { defaultValue = ''; } + if (colon === void 0) { colon = DEFAULT_COLON; } + if (cursorPosition === void 0) { cursorPosition = 0; } + if (disableHoursLimit === void 0) { disableHoursLimit = false; } + if (maxHoursLength === void 0) { maxHoursLength = DEFAULT_HOURS_LENGTH; } + var _a = defaultValue.split(colon), oldH = _a[0], oldM = _a[1], oldS = _a[2]; + var newCursorPosition = Number(cursorPosition); + var _b = String(value).split(colon), newH = _b[0], newM = _b[1], newS = _b[2]; + newH = formatTimeItem(newH, maxHoursLength); + if (!disableHoursLimit) { + if (Number(newH[0]) > 2) { + newH = oldH; + newCursorPosition -= 1; + } + else if (Number(newH[0]) === 2) { + if (Number(oldH[0]) === 2 && Number(newH[1]) > 3) { + newH = "2" + oldH[1]; + newCursorPosition -= 2; + } + else if (Number(newH[1]) > 3) { + newH = '23'; + } + } + } + newM = formatTimeItem(newM); + if (Number(newM[0]) > 5) { + newM = oldM; + newCursorPosition -= 1; + } + if (showSeconds) { + newS = formatTimeItem(newS); + if (Number(newS[0]) > 5) { + newS = oldS; + newCursorPosition -= 1; + } + } + var validatedValue = showSeconds ? "" + newH + colon + newM + colon + newS : "" + newH + colon + newM; + return [validatedValue, newCursorPosition]; +} +exports.validateTimeAndCursor = validateTimeAndCursor; +var TimeField = /** @class */ (function (_super) { + __extends(TimeField, _super); + function TimeField(props) { + var _this = _super.call(this, props) || this; + var _showSeconds = Boolean(props.showSeconds); + var _colon = props.colon && props.colon.length === 1 ? props.colon : DEFAULT_COLON; + var _disableHoursLimit = Boolean(props.disableHoursLimit); + var _maxHoursLength = _disableHoursLimit && Number(props.maxHoursLength) > DEFAULT_HOURS_LENGTH + ? Number(props.maxHoursLength) + : DEFAULT_HOURS_LENGTH; + var _defaultValue = _showSeconds + ? _maxHoursLength > 2 + ? DEFAULT_VALUE_HOURS_LEN(_maxHoursLength) + : DEFAULT_VALUE_FULL + : DEFAULT_VALUE_SHORT; + var validatedTime = validateTimeAndCursor(_showSeconds, _this.props.value, _defaultValue, _colon, 0, _disableHoursLimit, _maxHoursLength)[0]; + _this.state = { + value: validatedTime, + _colon: _colon, + _showSeconds: _showSeconds, + _defaultValue: _defaultValue, + _maxLength: _defaultValue.length, + _disableHoursLimit: _disableHoursLimit, + _maxHoursLength: _maxHoursLength + }; + if (_disableHoursLimit && _maxHoursLength > DEFAULT_HOURS_LENGTH) { + var shift_1 = _maxHoursLength - DEFAULT_HOURS_LENGTH; + _this.colonPositions = DEFAULT_COLON_POSITIONS.map(function (pos) { return pos + shift_1; }); + _this.numberPositions = DEFAULT_NUMBER_POSITIONS.map(function (pos) { return pos + shift_1; }); + } + else { + _this.colonPositions = DEFAULT_COLON_POSITIONS; + _this.numberPositions = DEFAULT_NUMBER_POSITIONS; + } + _this.onInputChange = _this.onInputChange.bind(_this); + return _this; + } + TimeField.prototype.componentDidUpdate = function (prevProps) { + if (this.props.value !== prevProps.value) { + var validatedTime = validateTimeAndCursor(this.state._showSeconds, this.props.value, this.state._defaultValue, this.state._colon, 0, this.state._disableHoursLimit, this.state._maxHoursLength)[0]; + this.setState({ + value: validatedTime + }); + } + }; + TimeField.prototype.onInputChange = function (event, callback) { + var oldValue = this.state.value; + var inputEl = event.target; + var inputValue = inputEl.value; + var position = inputEl.selectionEnd || 0; + var isTyped = inputValue.length > oldValue.length; + var cursorCharacter = inputValue[position - 1]; + var addedCharacter = isTyped ? cursorCharacter : null; + var removedCharacter = isTyped ? null : oldValue[position]; + var replacedSingleCharacter = inputValue.length === oldValue.length ? oldValue[position - 1] : null; + var colon = this.state._colon; + var newValue = oldValue; + var newPosition = position; + if (addedCharacter !== null) { + if (position > this.state._maxLength) { + newPosition = this.state._maxLength; + } + else if (this.colonPositions.includes(position) && addedCharacter === colon) { + newValue = "" + inputValue.substr(0, position - 1) + colon + inputValue.substr(position + 1); + } + else if (this.colonPositions.includes(position) && isNumber(addedCharacter)) { + newValue = "" + inputValue.substr(0, position - 1) + colon + addedCharacter + inputValue.substr(position + 2); + newPosition = position + 1; + } + else if (isNumber(addedCharacter)) { + // user typed a number + newValue = inputValue.substr(0, position - 1) + addedCharacter + inputValue.substr(position + 1); + if (this.numberPositions.includes(position)) { + newPosition = position + 1; + } + } + else { + // if user typed NOT a number, then keep old value & position + newPosition = position - 1; + } + } + else if (replacedSingleCharacter !== null) { + // user replaced only a single character + if (isNumber(cursorCharacter)) { + if (this.numberPositions.includes(position - 1)) { + newValue = "" + inputValue.substr(0, position - 1) + colon + inputValue.substr(position); + } + else { + newValue = inputValue; + } + } + else { + // user replaced a number on some non-number character + newValue = oldValue; + newPosition = position - 1; + } + } + else if (typeof cursorCharacter !== 'undefined' && cursorCharacter !== colon && !isNumber(cursorCharacter)) { + // set of characters replaced by non-number + newValue = oldValue; + newPosition = position - 1; + } + else if (removedCharacter !== null) { + if (this.numberPositions.includes(position) && removedCharacter === colon) { + newValue = inputValue.substr(0, position - 1) + "0" + colon + inputValue.substr(position); + newPosition = position - 1; + } + else { + // user removed a number + newValue = inputValue.substr(0, position) + "0" + inputValue.substr(position); + } + } + var _a = validateTimeAndCursor(this.state._showSeconds, newValue, oldValue, colon, newPosition, this.state._disableHoursLimit, this.state._maxHoursLength), validatedTime = _a[0], validatedCursorPosition = _a[1]; + this.setState({ value: validatedTime }, function () { + inputEl.selectionStart = validatedCursorPosition; + inputEl.selectionEnd = validatedCursorPosition; + callback(event, validatedTime); + }); + event.persist(); + }; + TimeField.prototype.render = function () { + var _this = this; + var value = this.state.value; + var _a = this.props, onChange = _a.onChange, style = _a.style, showSeconds = _a.showSeconds, input = _a.input, inputRef = _a.inputRef, colon = _a.colon, props = __rest(_a, ["onChange", "style", "showSeconds", "input", "inputRef", "colon"]); //eslint-disable-line no-unused-vars + var onChangeHandler = function (event) { + return _this.onInputChange(event, function (e, v) { return onChange && onChange(e, v); }); + }; + if (input) { + return react_1.default.cloneElement(input, __assign(__assign({}, props), { value: value, + style: style, onChange: onChangeHandler })); + } + return (react_1.default.createElement("input", __assign({ type: "text" }, props, { ref: inputRef, value: value, onChange: onChangeHandler, style: __assign({ width: showSeconds ? 54 : 35 }, style) }))); + }; + TimeField.defaultProps = { + showSeconds: false, + input: null, + style: {}, + colon: DEFAULT_COLON, + disableHoursLimit: false, + maxHoursLength: DEFAULT_HOURS_LENGTH + }; + return TimeField; +}(react_1.default.Component)); +exports.default = TimeField; diff --git a/src/index.tsx b/src/index.tsx index 8b711ff..8bb1fad 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,14 +3,20 @@ import React, {ChangeEvent, CSSProperties, ReactElement} from 'react'; const DEFAULT_COLON = ':'; const DEFAULT_VALUE_SHORT = `00${DEFAULT_COLON}00`; const DEFAULT_VALUE_FULL = `00${DEFAULT_COLON}00${DEFAULT_COLON}00`; +const DEFAULT_HOURS_LENGTH = 2; +const DEFAULT_VALUE_HOURS_LEN = (len = DEFAULT_HOURS_LENGTH): string => + `${'0'.repeat(len)}${DEFAULT_COLON}00${DEFAULT_COLON}00`; +const DEFAULT_COLON_POSITIONS = [3, 6]; +const DEFAULT_NUMBER_POSITIONS = [2, 5]; export function isNumber(value: T): boolean { const number = Number(value); return !isNaN(number) && String(value) === String(number); } -export function formatTimeItem(value?: string | number): string { - return `${value || ''}00`.substr(0, 2); +export function formatTimeItem(value?: string | number, len = DEFAULT_HOURS_LENGTH): string { + const zeros = '0'.repeat(len); + return `${value || ''}${zeros}`.substr(0, len); } export function validateTimeAndCursor( @@ -18,23 +24,27 @@ export function validateTimeAndCursor( value = '', defaultValue = '', colon = DEFAULT_COLON, - cursorPosition = 0 + cursorPosition = 0, + disableHoursLimit = false, + maxHoursLength = DEFAULT_HOURS_LENGTH ): [string, number] { const [oldH, oldM, oldS] = defaultValue.split(colon); let newCursorPosition = Number(cursorPosition); let [newH, newM, newS] = String(value).split(colon); - newH = formatTimeItem(newH); - if (Number(newH[0]) > 2) { - newH = oldH; - newCursorPosition -= 1; - } else if (Number(newH[0]) === 2) { - if (Number(oldH[0]) === 2 && Number(newH[1]) > 3) { - newH = `2${oldH[1]}`; - newCursorPosition -= 2; - } else if (Number(newH[1]) > 3) { - newH = '23'; + newH = formatTimeItem(newH, maxHoursLength); + if (!disableHoursLimit) { + if (Number(newH[0]) > 2) { + newH = oldH; + newCursorPosition -= 1; + } else if (Number(newH[0]) === 2) { + if (Number(oldH[0]) === 2 && Number(newH[1]) > 3) { + newH = `2${oldH[1]}`; + newCursorPosition -= 2; + } else if (Number(newH[1]) > 3) { + newH = '23'; + } } } @@ -67,6 +77,8 @@ interface Props { inputRef?: () => HTMLInputElement | null; colon?: string; style?: CSSProperties | {}; + disableHoursLimit?: boolean; + maxHoursLength?: number; } interface State { @@ -75,32 +87,68 @@ interface State { _defaultValue: string; _showSeconds: boolean; _maxLength: number; + _disableHoursLimit: boolean; + _maxHoursLength: number; } export default class TimeField extends React.Component { + private numberPositions: number[]; + private colonPositions: number[]; + static defaultProps: Props = { showSeconds: false, input: null, style: {}, - colon: DEFAULT_COLON + colon: DEFAULT_COLON, + disableHoursLimit: false, + maxHoursLength: DEFAULT_HOURS_LENGTH }; constructor(props: Props) { super(props); const _showSeconds = Boolean(props.showSeconds); - const _defaultValue = _showSeconds ? DEFAULT_VALUE_FULL : DEFAULT_VALUE_SHORT; const _colon = props.colon && props.colon.length === 1 ? props.colon : DEFAULT_COLON; - const [validatedTime] = validateTimeAndCursor(_showSeconds, this.props.value, _defaultValue, _colon); + const _disableHoursLimit = Boolean(props.disableHoursLimit); + const _maxHoursLength = + _disableHoursLimit && Number(props.maxHoursLength) > DEFAULT_HOURS_LENGTH + ? Number(props.maxHoursLength) + : DEFAULT_HOURS_LENGTH; + const _defaultValue = _showSeconds + ? _maxHoursLength > 2 + ? DEFAULT_VALUE_HOURS_LEN(_maxHoursLength) + : DEFAULT_VALUE_FULL + : DEFAULT_VALUE_SHORT; + + const [validatedTime] = validateTimeAndCursor( + _showSeconds, + this.props.value, + _defaultValue, + _colon, + 0, + _disableHoursLimit, + _maxHoursLength + ); this.state = { value: validatedTime, _colon, _showSeconds, _defaultValue, - _maxLength: _defaultValue.length + _maxLength: _defaultValue.length, + _disableHoursLimit, + _maxHoursLength }; + if (_disableHoursLimit && _maxHoursLength > DEFAULT_HOURS_LENGTH) { + const shift = _maxHoursLength - DEFAULT_HOURS_LENGTH; + this.colonPositions = DEFAULT_COLON_POSITIONS.map((pos) => pos + shift); + this.numberPositions = DEFAULT_NUMBER_POSITIONS.map((pos) => pos + shift); + } else { + this.colonPositions = DEFAULT_COLON_POSITIONS; + this.numberPositions = DEFAULT_NUMBER_POSITIONS; + } + this.onInputChange = this.onInputChange.bind(this); } @@ -110,7 +158,10 @@ export default class TimeField extends React.Component { this.state._showSeconds, this.props.value, this.state._defaultValue, - this.state._colon + this.state._colon, + 0, + this.state._disableHoursLimit, + this.state._maxHoursLength ); this.setState({ value: validatedTime @@ -136,15 +187,15 @@ export default class TimeField extends React.Component { if (addedCharacter !== null) { if (position > this.state._maxLength) { newPosition = this.state._maxLength; - } else if ((position === 3 || position === 6) && addedCharacter === colon) { + } else if (this.colonPositions.includes(position) && addedCharacter === colon) { newValue = `${inputValue.substr(0, position - 1)}${colon}${inputValue.substr(position + 1)}`; - } else if ((position === 3 || position === 6) && isNumber(addedCharacter)) { + } else if (this.colonPositions.includes(position) && isNumber(addedCharacter)) { newValue = `${inputValue.substr(0, position - 1)}${colon}${addedCharacter}${inputValue.substr(position + 2)}`; newPosition = position + 1; } else if (isNumber(addedCharacter)) { // user typed a number newValue = inputValue.substr(0, position - 1) + addedCharacter + inputValue.substr(position + 1); - if (position === 2 || position === 5) { + if (this.numberPositions.includes(position)) { newPosition = position + 1; } } else { @@ -154,7 +205,7 @@ export default class TimeField extends React.Component { } else if (replacedSingleCharacter !== null) { // user replaced only a single character if (isNumber(cursorCharacter)) { - if (position - 1 === 2 || position - 1 === 5) { + if (this.numberPositions.includes(position - 1)) { newValue = `${inputValue.substr(0, position - 1)}${colon}${inputValue.substr(position)}`; } else { newValue = inputValue; @@ -169,7 +220,7 @@ export default class TimeField extends React.Component { newValue = oldValue; newPosition = position - 1; } else if (removedCharacter !== null) { - if ((position === 2 || position === 5) && removedCharacter === colon) { + if (this.numberPositions.includes(position) && removedCharacter === colon) { newValue = `${inputValue.substr(0, position - 1)}0${colon}${inputValue.substr(position)}`; newPosition = position - 1; } else { @@ -183,7 +234,9 @@ export default class TimeField extends React.Component { newValue, oldValue, colon, - newPosition + newPosition, + this.state._disableHoursLimit, + this.state._maxHoursLength ); this.setState({value: validatedTime}, () => {