From feedeb0e678c7d98a849adb315e22bd69145210b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Sun, 14 Mar 2021 01:02:19 +0100 Subject: [PATCH] add fallback process for old browsers/mobile, replace classes by css pseudo-classes --- package.json | 7 +- src/_demo/script.tsx | 56 ++++---- src/_demo/styles.scss | 83 ++++++++---- src/kb-event/kb-event.test.ts | 13 +- src/kb-event/kb-event.ts | 4 - src/pin-field/pin-field.spec.tsx | 41 ++++-- src/pin-field/pin-field.test.ts | 163 +++++++++++++++------- src/pin-field/pin-field.tsx | 224 +++++++++++++++++++------------ src/pin-field/pin-field.types.ts | 8 +- src/utils/index.ts | 1 + src/utils/utils.test.ts | 29 ++++ src/utils/utils.ts | 23 ++++ yarn.lock | 16 +-- 13 files changed, 445 insertions(+), 223 deletions(-) create mode 100644 src/utils/index.ts create mode 100644 src/utils/utils.test.ts create mode 100644 src/utils/utils.ts diff --git a/package.json b/package.json index 5fe52c6..5780c93 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "lib/pin-field/pin-field.{types.js|types.js.map|types.d.ts|js|js.map|d.ts}", "lib/mvu/mvu.{types.js|types.js.map|types.d.ts|js|js.map|d.ts}", "lib/kb-event/kb-event.{types.js|types.js.map|types.d.ts|js|js.map|d.ts}", + "lib/utils/utils.{js|js.map|d.ts}", "lib/**/index.{js|js.map|d.ts}", "lib/index.{js|js.map|d.ts}" ], @@ -73,9 +74,6 @@ "react": "^16.8 || ^17", "react-dom": "^16.8 || ^17" }, - "dependencies": { - "classnames": "^2.2.6" - }, "scripts": { "start": "parcel serve -d lib -p 3000 src/_demo/index.html", "build": "tsc", @@ -84,5 +82,8 @@ "test:unit": "jest", "test:e2e": "cypress run", "test": "run-p test:unit test:e2e" + }, + "dependencies": { + "classnames": "^2.2.6" } } diff --git a/src/_demo/script.tsx b/src/_demo/script.tsx index 5a66992..f9abfa7 100644 --- a/src/_demo/script.tsx +++ b/src/_demo/script.tsx @@ -1,6 +1,6 @@ import React, {FC, useRef, useState} from "react"; import ReactDOM from "react-dom"; -import classNames from "classnames"; +import cn from "classnames"; import PinField from ".."; @@ -47,9 +47,9 @@ const App: FC = () => { $ yarn add react-pin-field -
+
setDemoCompleted(true)} format={k => k.toUpperCase()} autoFocus @@ -61,11 +61,15 @@ const App: FC = () => {

Default

- +
+ +

With ref

You can control each inputs with the pin field ref:

- +
+ +
); diff --git a/src/_demo/styles.scss b/src/_demo/styles.scss index 972e5ec..21e8a3a 100644 --- a/src/_demo/styles.scss +++ b/src/_demo/styles.scss @@ -2,48 +2,83 @@ $black: #333333; $blue: #686de0; $gray: #d3d3d3; $green: #6ab04c; +$red: #dc3545; $white: #ffffff; code { - padding: 5px 10px; display: inline-block; + padding: 0.25rem 0.5rem; } -.container-a { +.pin-field-container { display: grid; - grid-auto-flow: column; grid-auto-columns: max-content; - grid-column-gap: 10px; + grid-auto-flow: column; justify-content: center; - margin: 64px 0; + margin: 4rem 0; } -.field-a { - width: 75px; - height: 75px; - font-size: 40px; - text-align: center; - outline: none; - border-radius: 5px; +.pin-field { border: 1px solid $gray; - transition-property: color, border, box-shadow, transform; + border-right: none; + font-size: 2rem; + height: 4rem; + outline: none; + text-align: center; transition-duration: 250ms; + transition-property: color, border, box-shadow, transform; + width: 4rem; + + &:first-of-type { + border-radius: 0.5rem 0 0 0.5rem; + } + + &:last-of-type { + border-radius: 0 0.5rem 0.5rem 0; + border-right: 1px solid $gray; + } &:focus { + border-color: $blue; + box-shadow: 0 0 0.25rem rgba($blue, 0.5); outline: none; - box-shadow: 0 0 7px rgba($blue, 0.5); - border: 1px solid $blue; - transform: scale(1.05); + + & + .pin-field { + border-left-color: $blue; + } + } + + &:invalid { + animation: shake 5 linear 75ms; + border-color: $red; + box-shadow: 0 0 0.25rem rgba($red, 0.5); + + & + .pin-field { + border-left-color: $red; + } } -} -.field-a-complete { - border: 1px solid $green; - color: $green; + &.complete { + border-color: $green; + color: $green; - &[disabled] { - background: rgba($green, 0.1); - opacity: 0.5; - cursor: not-allowed; + &[disabled] { + background: rgba($green, 0.1); + cursor: not-allowed; + opacity: 0.5; + } + + & + .pin-field { + border-left-color: $green; + } + } +} + +@keyframes shake { + from { + transform: scale(1.05) translateX(-3%); + } + to { + transform: scale(1.05) translateX(3%); } } diff --git a/src/kb-event/kb-event.test.ts b/src/kb-event/kb-event.test.ts index b3364a4..ab10fef 100644 --- a/src/kb-event/kb-event.test.ts +++ b/src/kb-event/kb-event.test.ts @@ -1,4 +1,4 @@ -import {getKeyFromKeyboardEvent, getKeyFromInputEvent} from "./kb-event"; +import {getKeyFromKeyboardEvent} from "./kb-event"; test("getKeyFromKeyboardEvent", () => { const cases = [ @@ -13,14 +13,3 @@ test("getKeyFromKeyboardEvent", () => { expect(getKeyFromKeyboardEvent(evt)).toEqual(expected); }); }); - -test("getKeyFromInputEvent", () => { - const cases = [ - [{}, "Unidentified"], - [{data: "a"}, "a"], - ]; - - cases.forEach(([evt, expected]) => { - expect(getKeyFromInputEvent(evt as InputEvent)).toEqual(expected); - }); -}); diff --git a/src/kb-event/kb-event.ts b/src/kb-event/kb-event.ts index 2eda4b2..73e03f9 100644 --- a/src/kb-event/kb-event.ts +++ b/src/kb-event/kb-event.ts @@ -107,7 +107,3 @@ export function getKeyFromKeyboardEvent(evt: KeyboardEvent) { return key; } - -export function getKeyFromInputEvent(evt: InputEvent) { - return evt.data || "Unidentified"; -} diff --git a/src/pin-field/pin-field.spec.tsx b/src/pin-field/pin-field.spec.tsx index da4d54d..7076925 100644 --- a/src/pin-field/pin-field.spec.tsx +++ b/src/pin-field/pin-field.spec.tsx @@ -1,27 +1,26 @@ import React from "react"; import enzyme, {shallow, mount} from "enzyme"; -import noop from "lodash/fp/noop"; import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import PinField from "./pin-field"; +import {noop} from "../utils"; enzyme.configure({adapter: new Adapter()}); +jest.spyOn(console, "debug").mockImplementation(noop); + test("structure", () => { const wrapper = shallow(); const inputs = wrapper.find("input"); expect(inputs).toHaveLength(5); - inputs.forEach((input, idx) => { + inputs.forEach(input => { expect(input.prop("type")).toBe("text"); - expect(input.hasClass("a-reactPinField__input")).toBe(true); - expect(input.hasClass(`-${idx}`)).toBe(true); expect(input.prop("autoFocus")).toBe(false); expect(typeof input.prop("onFocus")).toBe("function"); expect(typeof input.prop("onKeyDown")).toBe("function"); expect(typeof input.prop("onPaste")).toBe("function"); expect(input.prop("maxLength")).toBe(1); - expect(input.prop("style")).toEqual({}); }); }); @@ -47,7 +46,6 @@ test("autoFocus", () => { inputs.forEach((input, idx) => { expect(input.prop("autoFocus")).toBe(idx === 0); - expect(input.hasClass("-focus")).toBe(idx === 0); }); }); @@ -56,7 +54,6 @@ test("className", () => { const inputs = wrapper.find("input"); inputs.forEach(input => { - expect(input.hasClass("a-reactPinField__input")).toBe(true); expect(input.hasClass("custom-class-name")).toBe(true); }); }); @@ -77,12 +74,32 @@ test("events", () => { const input = wrapper.find("input").first(); input.simulate("focus"); - input.simulate("keydown", {preventDefault: noop, nativeEvent: {key: "a"}}); - input.simulate("keydown", {preventDefault: noop, nativeEvent: {which: 66}}); - input.simulate("input", {preventDefault: noop, nativeEvent: {data: "c"}}); - input.simulate("paste", {clipboardData: {getData: () => "d"}}); + input.simulate("keydown", {preventDefault: noop, nativeEvent: {key: "Alt", target: document.createElement("input")}}); + input.simulate("keydown", {preventDefault: noop, nativeEvent: {key: "a", target: document.createElement("input")}}); + input.simulate("keydown", {preventDefault: noop, nativeEvent: {which: 66, target: document.createElement("input")}}); + input.simulate("paste", {clipboardData: {getData: () => "cde"}}); - expect(handleChangeMock).toHaveBeenCalledTimes(4); + expect(handleChangeMock).toHaveBeenCalledTimes(3); expect(handleCompleteMock).toHaveBeenCalledTimes(1); expect(handleCompleteMock).toHaveBeenCalledWith("abcd"); }); + +test("fallback events", () => { + const handleChangeMock = jest.fn(); + const handleCompleteMock = jest.fn(); + const wrapper = mount(); + const input = wrapper.find("input").first(); + + const keyDownInputMock = document.createElement("input"); + keyDownInputMock.value = ""; + const keyUpInputMock = document.createElement("input"); + keyUpInputMock.value = "a"; + + input.simulate("focus"); + input.simulate("keydown", {preventDefault: noop, nativeEvent: {key: "Unidentified", target: keyDownInputMock}}); + input.simulate("keyup", {preventDefault: noop, nativeEvent: {target: keyUpInputMock}}); + input.simulate("keydown", {preventDefault: noop, nativeEvent: {key: "Unidentified", target: keyDownInputMock}}); + input.simulate("keyup", {preventDefault: noop, nativeEvent: {target: {value: "b"}}}); + expect(handleChangeMock).toHaveBeenCalledTimes(1); + expect(handleChangeMock).toHaveBeenCalledWith("a"); +}); diff --git a/src/pin-field/pin-field.test.ts b/src/pin-field/pin-field.test.ts index 7e2bfab..e573a55 100644 --- a/src/pin-field/pin-field.test.ts +++ b/src/pin-field/pin-field.test.ts @@ -1,20 +1,20 @@ import "react"; import * as pinField from "./pin-field"; +import {noop} from "../utils"; jest.mock("react", () => ({ useCallback: (f: any) => f, forwardRef: (f: any) => f, })); +jest.spyOn(console, "debug").mockImplementation(noop); + function mockInput(value: string) { const setValMock = jest.fn(); const ref = { focus: jest.fn(), - classList: { - add: jest.fn(), - remove: jest.fn(), - }, + setCustomValidity: jest.fn(), set value(val: string) { setValMock(val); }, @@ -30,7 +30,7 @@ test("constants", () => { const {NO_EFFECT, PROP_KEYS, HANDLER_KEYS, IGNORED_META_KEYS} = pinField; expect(NO_EFFECT).toEqual([]); - expect(PROP_KEYS).toEqual(["autoFocus", "className", "length", "validate", "format", "style"]); + expect(PROP_KEYS).toEqual(["autoFocus", "length", "validate", "format"]); expect(HANDLER_KEYS).toEqual(["onResolveKey", "onRejectKey", "onChange", "onComplete"]); expect(IGNORED_META_KEYS).toEqual(["Alt", "Control", "Enter", "Meta", "Shift", "Tab"]); }); @@ -38,7 +38,6 @@ test("constants", () => { test("default props", () => { const {defaultProps} = pinField; - expect(defaultProps).toHaveProperty("className", ""); expect(defaultProps).toHaveProperty("length", 5); expect(defaultProps).toHaveProperty("validate", /^[a-zA-Z0-9]$/); expect(defaultProps).toHaveProperty("format"); @@ -50,7 +49,6 @@ test("default props", () => { expect(defaultProps.onChange("a")).toStrictEqual(undefined); expect(defaultProps).toHaveProperty("onComplete"); expect(defaultProps.onComplete("a")).toStrictEqual(undefined); - expect(defaultProps).toHaveProperty("style", {}); }); test("default state", () => { @@ -63,6 +61,7 @@ test("default state", () => { expect(typeof state.isKeyAllowed).toStrictEqual("function"); expect(state.isKeyAllowed("a")).toStrictEqual(true); expect(state.isKeyAllowed("@")).toStrictEqual(false); + expect(state).toHaveProperty("fallback", null); }); test("get previous focus index", () => { @@ -91,6 +90,7 @@ describe("is key allowed", () => { test("string", () => { const str = isKeyAllowed("a"); + expect(str("")).toStrictEqual(false); expect(str("a")).toStrictEqual(true); expect(str("b")).toStrictEqual(false); expect(str("ab")).toStrictEqual(false); @@ -136,18 +136,14 @@ describe("apply", () => { describe("handle-key-down", () => { test("unidentified", () => { - const [state, eff] = apply(currState, {type: "handle-key-down", key: "Unidentified"}); + const [state, eff] = apply(currState, {type: "handle-key-down", key: "Unidentified", idx: 0, val: ""}); expect(state).toMatchObject(state); - expect(eff).toEqual([ - {type: "set-input-val", idx: 0, val: ""}, - {type: "reject-key", idx: 0, key: "Unidentified"}, - {type: "handle-code-change"}, - ]); + expect(eff).toEqual([]); }); test("dead", () => { - const [state, eff] = apply(currState, {type: "handle-key-down", key: "Dead"}); + const [state, eff] = apply(currState, {type: "handle-key-down", key: "Dead", idx: 0, val: ""}); expect(state).toMatchObject(state); expect(eff).toEqual([ @@ -159,14 +155,17 @@ describe("apply", () => { describe("left arrow", () => { test("from the first input", () => { - const [state, eff] = apply(currState, {type: "handle-key-down", key: "ArrowLeft"}); + const [state, eff] = apply(currState, {type: "handle-key-down", key: "ArrowLeft", idx: 0, val: ""}); expect(state).toMatchObject({...state, focusIdx: 0}); expect(eff).toEqual([{type: "focus-input", idx: 0}]); }); test("from the last input", () => { - const [state, eff] = apply({...currState, focusIdx: 4}, {type: "handle-key-down", key: "ArrowLeft"}); + const [state, eff] = apply( + {...currState, focusIdx: 4}, + {type: "handle-key-down", key: "ArrowLeft", idx: 0, val: ""}, + ); expect(state).toMatchObject({...state, focusIdx: 3}); expect(eff).toEqual([{type: "focus-input", idx: 3}]); @@ -175,14 +174,17 @@ describe("apply", () => { describe("right arrow", () => { test("from the first input", () => { - const [state, eff] = apply(currState, {type: "handle-key-down", key: "ArrowRight"}); + const [state, eff] = apply(currState, {type: "handle-key-down", key: "ArrowRight", idx: 0, val: ""}); expect(state).toMatchObject({...state, focusIdx: 1}); expect(eff).toEqual([{type: "focus-input", idx: 1}]); }); test("from the last input", () => { - const [state, eff] = apply({...currState, focusIdx: 4}, {type: "handle-key-down", key: "ArrowRight"}); + const [state, eff] = apply( + {...currState, focusIdx: 4}, + {type: "handle-key-down", key: "ArrowRight", idx: 0, val: ""}, + ); expect(state).toMatchObject({...state, focusIdx: 4}); expect(eff).toEqual([{type: "focus-input", idx: 4}]); @@ -190,14 +192,14 @@ describe("apply", () => { }); test("backspace", () => { - const [state, eff] = apply(currState, {type: "handle-key-down", key: "Backspace"}); + const [state, eff] = apply(currState, {type: "handle-key-down", key: "Backspace", idx: 0, val: ""}); expect(state).toMatchObject({...state, focusIdx: 0}); expect(eff).toEqual([{type: "handle-delete", idx: 0}, {type: "handle-code-change"}]); }); test("delete", () => { - const [state, eff] = apply(currState, {type: "handle-key-down", key: "Delete"}); + const [state, eff] = apply(currState, {type: "handle-key-down", key: "Delete", idx: 0, val: ""}); expect(state).toMatchObject({...state, focusIdx: 0}); expect(eff).toEqual([{type: "handle-delete", idx: 0}, {type: "handle-code-change"}]); @@ -205,7 +207,7 @@ describe("apply", () => { describe("default", () => { test("resolve", () => { - const [state, eff] = apply(currState, {type: "handle-key-down", key: "a"}); + const [state, eff] = apply(currState, {type: "handle-key-down", key: "a", idx: 0, val: ""}); expect(state).toMatchObject({...state, focusIdx: 1}); expect(eff).toEqual([ @@ -217,7 +219,7 @@ describe("apply", () => { }); test("reject", () => { - const [state, eff] = apply(currState, {type: "handle-key-down", key: "@"}); + const [state, eff] = apply(currState, {type: "handle-key-down", key: "@", idx: 0, val: ""}); expect(state).toMatchObject(state); expect(eff).toEqual([{type: "reject-key", idx: 0, key: "@"}]); @@ -225,9 +227,64 @@ describe("apply", () => { }); }); + describe("handle-key-up", () => { + test("no fallback", () => { + const [state, eff] = apply(currState, {type: "handle-key-up", idx: 0, val: ""}); + + expect(state).toMatchObject(state); + expect(eff).toEqual([]); + }); + + test("empty prevVal, empty val", () => { + const [state, eff] = apply({...currState, fallback: {idx: 0, val: ""}}, {type: "handle-key-up", idx: 0, val: ""}); + + expect(state).toMatchObject({fallback: null}); + expect(eff).toEqual([{type: "handle-delete", idx: 0}, {type: "handle-code-change"}]); + }); + + test("empty prevVal, not empty allowed val", () => { + const [state, eff] = apply( + {...currState, fallback: {idx: 0, val: ""}}, + {type: "handle-key-up", idx: 0, val: "a"}, + ); + + expect(state).toMatchObject({fallback: null}); + expect(eff).toEqual([ + {type: "set-input-val", idx: 0, val: "a"}, + {type: "resolve-key", idx: 0, key: "a"}, + {type: "focus-input", idx: 1}, + {type: "handle-code-change"}, + ]); + }); + + test("empty prevVal, not empty denied val", () => { + const [state, eff] = apply( + {...currState, fallback: {idx: 0, val: ""}}, + {type: "handle-key-up", idx: 0, val: "@"}, + ); + + expect(state).toMatchObject({fallback: null}); + expect(eff).toEqual([ + {type: "set-input-val", idx: 0, val: ""}, + {type: "reject-key", idx: 0, key: "@"}, + {type: "handle-code-change"}, + ]); + }); + + test("not empty prevVal", () => { + const [state, eff] = apply( + {...currState, fallback: {idx: 0, val: "a"}}, + {type: "handle-key-up", idx: 0, val: "a"}, + ); + + expect(state).toMatchObject({fallback: null}); + expect(eff).toEqual([]); + }); + }); + describe("handle-paste", () => { test("paste smaller text than code length", () => { - const [state, eff] = apply(currState, {type: "handle-paste", val: "abc"}); + const [state, eff] = apply(currState, {type: "handle-paste", idx: 0, val: "abc"}); expect(state).toMatchObject({...state, focusIdx: 3}); expect(eff).toEqual([ @@ -240,7 +297,7 @@ describe("apply", () => { }); test("paste bigger text than code length", () => { - const [state, eff] = apply(currState, {type: "handle-paste", val: "abcdefgh"}); + const [state, eff] = apply(currState, {type: "handle-paste", idx: 0, val: "abcdefgh"}); expect(state).toMatchObject({...state, focusIdx: 4}); expect(eff).toEqual([ @@ -255,11 +312,18 @@ describe("apply", () => { }); test("paste on last input", () => { - const [state, eff] = apply({...currState, focusIdx: 4}, {type: "handle-paste", val: "abc"}); + const [state, eff] = apply({...currState, focusIdx: 4}, {type: "handle-paste", idx: 0, val: "abc"}); expect(state).toMatchObject({...state, focusIdx: 4}); expect(eff).toEqual([{type: "set-input-val", idx: 4, val: "a"}, {type: "handle-code-change"}]); }); + + test("paste with denied key", () => { + const [state, eff] = apply(currState, {type: "handle-paste", idx: 1, val: "ab@"}); + + expect(state).toMatchObject(state); + expect(eff).toEqual([{type: "reject-key", idx: 1, key: "ab@"}]); + }); }); test("focus-input", () => { @@ -303,10 +367,7 @@ describe("notify", () => { test("focus input", () => { notify({type: "focus-input", idx: 0}); - expect(inputA.ref.focus).toHaveBeenCalledTimes(1); - expect(inputA.ref.classList.add).toHaveBeenCalledTimes(1); - expect(inputA.ref.classList.add).toHaveBeenCalledWith("-focus"); }); describe("set input val", () => { @@ -316,8 +377,6 @@ describe("notify", () => { expect(propsFormatMock).toHaveBeenCalledTimes(1); expect(inputA.setValMock).toHaveBeenCalledTimes(1); expect(inputA.setValMock).toHaveBeenCalledWith(""); - expect(inputA.ref.classList.remove).toHaveBeenCalledTimes(1); - expect(inputA.ref.classList.remove).toHaveBeenCalledWith("-success"); }); test("non empty char", () => { @@ -326,17 +385,14 @@ describe("notify", () => { expect(propsFormatMock).toHaveBeenCalledTimes(1); expect(inputA.setValMock).toHaveBeenCalledTimes(1); expect(inputA.setValMock).toHaveBeenCalledWith("a"); - expect(inputA.ref.classList.remove).not.toHaveBeenCalled(); }); }); test("resolve key", () => { notify({type: "resolve-key", idx: 0, key: "a"}); - expect(inputA.ref.classList.remove).toHaveBeenCalledTimes(1); - expect(inputA.ref.classList.remove).toHaveBeenCalledWith("-error"); - expect(inputA.ref.classList.add).toHaveBeenCalledTimes(1); - expect(inputA.ref.classList.add).toHaveBeenCalledWith("-success"); + expect(inputA.ref.setCustomValidity).toHaveBeenCalledTimes(1); + expect(inputA.ref.setCustomValidity).toHaveBeenCalledWith(""); expect(propsMock.onResolveKey).toHaveBeenCalledTimes(1); expect(propsMock.onResolveKey).toHaveBeenCalledWith("a", inputA.ref); }); @@ -344,10 +400,8 @@ describe("notify", () => { test("reject key", () => { notify({type: "reject-key", idx: 0, key: "a"}); - expect(inputA.ref.classList.remove).toHaveBeenCalledTimes(1); - expect(inputA.ref.classList.remove).toHaveBeenCalledWith("-success"); - expect(inputA.ref.classList.add).toHaveBeenCalledTimes(1); - expect(inputA.ref.classList.add).toHaveBeenCalledWith("-error"); + expect(inputA.ref.setCustomValidity).toHaveBeenCalledTimes(1); + expect(inputA.ref.setCustomValidity).toHaveBeenCalledWith("Invalid key"); expect(propsMock.onRejectKey).toHaveBeenCalledTimes(1); expect(propsMock.onRejectKey).toHaveBeenCalledWith("a", inputA.ref); }); @@ -356,8 +410,8 @@ describe("notify", () => { test("from input A, not empty val", () => { notify({type: "handle-delete", idx: 0}); - expect(inputA.ref.classList.remove).toHaveBeenCalledTimes(1); - expect(inputA.ref.classList.remove).toHaveBeenCalledWith("-error", "-success"); + expect(inputA.ref.setCustomValidity).toHaveBeenCalledTimes(1); + expect(inputA.ref.setCustomValidity).toHaveBeenCalledWith(""); expect(inputA.setValMock).toHaveBeenCalledTimes(1); expect(inputA.setValMock).toHaveBeenCalledWith(""); }); @@ -365,8 +419,8 @@ describe("notify", () => { test("from input B, not empty val", () => { notify({type: "handle-delete", idx: 1}); - expect(inputB.ref.classList.remove).toHaveBeenCalledTimes(1); - expect(inputB.ref.classList.remove).toHaveBeenCalledWith("-error", "-success"); + expect(inputB.ref.setCustomValidity).toHaveBeenCalledTimes(1); + expect(inputB.ref.setCustomValidity).toHaveBeenCalledWith(""); expect(inputB.setValMock).toHaveBeenCalledTimes(1); expect(inputB.setValMock).toHaveBeenCalledWith(""); }); @@ -374,12 +428,12 @@ describe("notify", () => { test("from input C, empty val", () => { notify({type: "handle-delete", idx: 2}); - expect(inputC.ref.classList.remove).toHaveBeenCalledTimes(1); - expect(inputC.ref.classList.remove).toHaveBeenCalledWith("-error", "-success"); + expect(inputC.ref.setCustomValidity).toHaveBeenCalledTimes(1); + expect(inputC.ref.setCustomValidity).toHaveBeenCalledWith(""); expect(inputC.setValMock).toHaveBeenCalledTimes(1); expect(inputC.setValMock).toHaveBeenCalledWith(""); expect(inputB.ref.focus).toHaveBeenCalledTimes(1); - expect(inputB.ref.classList.remove).toHaveBeenCalledWith("-error", "-success"); + expect(inputB.ref.setCustomValidity).toHaveBeenCalledWith(""); expect(inputB.setValMock).toHaveBeenCalledTimes(1); expect(inputB.setValMock).toHaveBeenCalledWith(""); }); @@ -407,5 +461,22 @@ describe("notify", () => { expect(propsMock.onComplete).toHaveBeenCalledTimes(1); expect(propsMock.onComplete).toHaveBeenCalledWith("abc"); }); + + test("rtl", () => { + jest.spyOn(document.documentElement, "getAttribute").mockImplementation(() => "rtl"); + + const inputA = mockInput("a"); + const inputB = mockInput("b"); + const inputC = mockInput("c"); + const refs: React.RefObject = {current: [inputA.ref, inputB.ref, inputC.ref]}; + const notify = useNotifier({...propsMock, refs}); + + notify({type: "handle-code-change"}); + + expect(propsMock.onChange).toHaveBeenCalledTimes(1); + expect(propsMock.onChange).toHaveBeenCalledWith("cba"); + expect(propsMock.onComplete).toHaveBeenCalledTimes(1); + expect(propsMock.onComplete).toHaveBeenCalledWith("cba"); + }); }); }); diff --git a/src/pin-field/pin-field.tsx b/src/pin-field/pin-field.tsx index eb1fc97..8ebd18c 100644 --- a/src/pin-field/pin-field.tsx +++ b/src/pin-field/pin-field.tsx @@ -1,8 +1,8 @@ import React, {FC, forwardRef, useCallback, useImperativeHandle, useRef} from "react"; -import classNames from "classnames"; import {useMVU} from "../mvu"; -import {getKeyFromKeyboardEvent, getKeyFromInputEvent} from "../kb-event"; +import {getKeyFromKeyboardEvent} from "../kb-event"; +import {noop, range, omit, debug} from "../utils"; import { PinFieldDefaultProps as DefaultProps, @@ -14,25 +14,13 @@ import { PinFieldEffect as Effect, } from "./pin-field.types"; -const noop = (): undefined => undefined; -const range = (start: number, end: number) => Array.from({length: end}, (_, i) => i + start); -const omit = (keys: string[], obj: Record): Record => { - const poppedKey = keys.pop(); - if (!poppedKey) { - return obj; - } - const {[poppedKey]: omitted, ...rest} = obj; - return omit(keys, rest); -}; - export const NO_EFFECT: Effect[] = []; -export const PROP_KEYS = ["autoFocus", "className", "length", "validate", "format", "style"]; +export const PROP_KEYS = ["autoFocus", "length", "validate", "format"]; export const HANDLER_KEYS = ["onResolveKey", "onRejectKey", "onChange", "onComplete"]; export const IGNORED_META_KEYS = ["Alt", "Control", "Enter", "Meta", "Shift", "Tab"]; export const defaultProps: DefaultProps = { ref: {current: []}, - className: "", length: 5, validate: /^[a-zA-Z0-9]$/, format: key => key, @@ -40,7 +28,6 @@ export const defaultProps: DefaultProps = { onRejectKey: noop, onChange: noop, onComplete: noop, - style: {}, }; export function defaultState(props: Pick): State { @@ -48,6 +35,7 @@ export function defaultState(props: Pick): focusIdx: 0, codeLength: props.length, isKeyAllowed: isKeyAllowed(props.validate), + fallback: null, }; } @@ -74,55 +62,99 @@ export function isKeyAllowed(predicate: DefaultProps["validate"]) { export function apply(state: State, action: Action): [State, Effect[]] { switch (action.type) { case "handle-key-down": { + debug("reducer", "handle-key-down", `key=${action.key}`); + switch (action.key) { - case "Unidentified": + case "Unidentified": { + return [{...state, fallback: {idx: state.focusIdx, val: action.val}}, []]; + } + case "Dead": { - const effects: Effect[] = [ - {type: "set-input-val", idx: state.focusIdx, val: ""}, - {type: "reject-key", idx: state.focusIdx, key: action.key}, - {type: "handle-code-change"}, + return [ + state, + [ + {type: "set-input-val", idx: state.focusIdx, val: ""}, + {type: "reject-key", idx: state.focusIdx, key: action.key}, + {type: "handle-code-change"}, + ], ]; - return [state, effects]; } case "ArrowLeft": { const prevFocusIdx = getPrevFocusIdx(state.focusIdx); - const effects: Effect[] = [{type: "focus-input", idx: prevFocusIdx}]; - return [{...state, focusIdx: prevFocusIdx}, effects]; + return [{...state, focusIdx: prevFocusIdx}, [{type: "focus-input", idx: prevFocusIdx}]]; } case "ArrowRight": { const nextFocusIdx = getNextFocusIdx(state.focusIdx, state.codeLength); - const effects: Effect[] = [{type: "focus-input", idx: nextFocusIdx}]; - return [{...state, focusIdx: nextFocusIdx}, effects]; + return [{...state, focusIdx: nextFocusIdx}, [{type: "focus-input", idx: nextFocusIdx}]]; } case "Delete": case "Backspace": { - const effects: Effect[] = [{type: "handle-delete", idx: state.focusIdx}, {type: "handle-code-change"}]; - return [state, effects]; + return [state, [{type: "handle-delete", idx: state.focusIdx}, {type: "handle-code-change"}]]; } default: { if (state.isKeyAllowed(action.key)) { const nextFocusIdx = getNextFocusIdx(state.focusIdx, state.codeLength); - const effects: Effect[] = [ - {type: "set-input-val", idx: state.focusIdx, val: action.key}, - {type: "resolve-key", idx: state.focusIdx, key: action.key}, - {type: "focus-input", idx: nextFocusIdx}, - {type: "handle-code-change"}, + return [ + {...state, focusIdx: nextFocusIdx}, + [ + {type: "set-input-val", idx: state.focusIdx, val: action.key}, + {type: "resolve-key", idx: state.focusIdx, key: action.key}, + {type: "focus-input", idx: nextFocusIdx}, + {type: "handle-code-change"}, + ], ]; - return [{...state, focusIdx: nextFocusIdx}, effects]; } - const effects: Effect[] = [{type: "reject-key", idx: state.focusIdx, key: action.key}]; - return [state, effects]; + return [state, [{type: "reject-key", idx: state.focusIdx, key: action.key}]]; } } } + case "handle-key-up": { + if (!state.fallback) { + debug("reducer", "handle-key-up", "ignored"); + return [state, NO_EFFECT]; + } + + debug("reducer", "handle-key-up"); + const nextState: State = {...state, fallback: null}; + const effects: Effect[] = []; + const {idx, val: prevVal} = state.fallback; + const val = action.val; + + if (prevVal === "" && val === "") { + effects.push({type: "handle-delete", idx}, {type: "handle-code-change"}); + } else if (prevVal === "" && val !== "") { + if (state.isKeyAllowed(val)) { + effects.push( + {type: "set-input-val", idx, val}, + {type: "resolve-key", idx, key: val}, + {type: "focus-input", idx: getNextFocusIdx(idx, state.codeLength)}, + {type: "handle-code-change"}, + ); + } else { + effects.push( + {type: "set-input-val", idx: state.focusIdx, val: ""}, + {type: "reject-key", idx, key: val}, + {type: "handle-code-change"}, + ); + } + } + + return [nextState, effects]; + } + case "handle-paste": { - if (!action.val.split("").every(state.isKeyAllowed)) return [state, NO_EFFECT]; + if (!action.val.split("").slice(0, state.codeLength).every(state.isKeyAllowed)) { + debug("reducer", "handle-paste", `rejected,val=${action.val}`); + return [state, [{type: "reject-key", idx: action.idx, key: action.val}]]; + } + + debug("reducer", "handle-paste", `val=${action.val}`); const pasteLen = Math.min(action.val.length, state.codeLength - state.focusIdx); const nextFocusIdx = getNextFocusIdx(pasteLen + state.focusIdx - 1, state.codeLength); const effects: Effect[] = range(0, pasteLen).map(idx => ({ @@ -141,12 +173,12 @@ export function apply(state: State, action: Action): [State, Effect[]] { } case "focus-input": { - const effects: Effect[] = [{type: "focus-input", idx: action.idx}]; - return [{...state, focusIdx: action.idx}, effects]; + return [{...state, focusIdx: action.idx}, [{type: "focus-input", idx: action.idx}]]; } - default: + default: { return [state, NO_EFFECT]; + } } } @@ -154,42 +186,46 @@ export function useNotifier({refs, ...props}: NotifierProps) { return useCallback( (eff: Effect) => { switch (eff.type) { - case "focus-input": + case "focus-input": { + debug("notifier", "focus-input", `idx=${eff.idx}`); refs.current[eff.idx].focus(); - refs.current.forEach(input => input.classList.remove("-focus")); - refs.current[eff.idx].classList.add("-focus"); break; + } case "set-input-val": { + debug("notifier", "set-input-val", `idx=${eff.idx},val=${eff.val}`); const val = props.format(eff.val); refs.current[eff.idx].value = val; - if (val === "") refs.current[eff.idx].classList.remove("-success"); break; } - case "resolve-key": - refs.current[eff.idx].classList.remove("-error"); - refs.current[eff.idx].classList.add("-success"); + case "resolve-key": { + debug("notifier", "resolve-key", `idx=${eff.idx},key=${eff.key}`); + refs.current[eff.idx].setCustomValidity(""); props.onResolveKey(eff.key, refs.current[eff.idx]); break; + } - case "reject-key": - refs.current[eff.idx].classList.remove("-success"); - refs.current[eff.idx].classList.add("-error"); + case "reject-key": { + debug("notifier", "reject-key", `idx=${eff.idx},key=${eff.key}`); + refs.current[eff.idx].setCustomValidity("Invalid key"); props.onRejectKey(eff.key, refs.current[eff.idx]); break; + } case "handle-delete": { + debug("notifier", "handle-delete", `idx=${eff.idx}`); const prevVal = refs.current[eff.idx].value; - refs.current[eff.idx].classList.remove("-error", "-success"); + refs.current[eff.idx].setCustomValidity(""); refs.current[eff.idx].value = ""; if (!prevVal) { const prevIdx = getPrevFocusIdx(eff.idx); refs.current[prevIdx].focus(); - refs.current[prevIdx].classList.remove("-error", "-success"); + refs.current[prevIdx].setCustomValidity(""); refs.current[prevIdx].value = ""; } + break; } @@ -197,13 +233,15 @@ export function useNotifier({refs, ...props}: NotifierProps) { const dir = (document.documentElement.getAttribute("dir") || "ltr").toLowerCase(); const codeArr = refs.current.map(r => r.value.trim()); const code = (dir === "rtl" ? codeArr.reverse() : codeArr).join(""); + debug("notifier", "handle-code-change", `code={${code}}`); props.onChange(code); code.length === props.length && props.onComplete(code); break; } - default: + default: { break; + } } }, [props, refs], @@ -212,7 +250,7 @@ export function useNotifier({refs, ...props}: NotifierProps) { export const PinField: FC = forwardRef((customProps, fwdRef) => { const props: DefaultProps & InputProps = {...defaultProps, ...customProps}; - const {autoFocus, className, length: codeLength, style} = props; + const {autoFocus, length: codeLength} = props; const inputProps: InputProps = omit([...PROP_KEYS, ...HANDLER_KEYS], props); const refs = useRef([]); const model = defaultState(props); @@ -221,39 +259,57 @@ export const PinField: FC = forwardRef((customProps, fwdRef) => { useImperativeHandle(fwdRef, () => refs.current, [refs]); - function setRefAtIndex(idx: number) { - return (ref: HTMLInputElement) => { - if (ref) { - refs.current[idx] = ref; - } + function handleFocus(idx: number) { + return function () { + debug("main", "event", `focus,idx=${idx}`); + dispatch({type: "focus-input", idx}); }; } - function handleFocus(idx: number) { - return () => dispatch({type: "focus-input", idx}); + function handleKeyDown(idx: number) { + return function (evt: React.KeyboardEvent) { + const key = getKeyFromKeyboardEvent(evt.nativeEvent); + + if ( + !IGNORED_META_KEYS.includes(key) && + !evt.ctrlKey && + !evt.altKey && + !evt.metaKey && + evt.nativeEvent.target instanceof HTMLInputElement + ) { + evt.preventDefault(); + debug("main", "event", `key-down,idx=${idx},key=${key}`); + dispatch({type: "handle-key-down", idx, key, val: evt.nativeEvent.target.value}); + } else { + debug("main", "event", `key-down,idx=${idx},ignored-key=${key}`); + } + }; } - function handleKeyDown(evt: React.KeyboardEvent) { - const key = getKeyFromKeyboardEvent(evt.nativeEvent); - - if (!IGNORED_META_KEYS.includes(key) && !evt.ctrlKey && !evt.altKey && !evt.metaKey) { - evt.preventDefault(); - dispatch({type: "handle-key-down", key}); - } + function handleKeyUp(idx: number) { + return function (evt: React.KeyboardEvent) { + if (evt.nativeEvent.target instanceof HTMLInputElement) { + debug("main", "event", `key-up,idx=${idx}`); + dispatch({type: "handle-key-up", idx, val: evt.nativeEvent.target.value}); + } + }; } - function handleInput(evt: React.FormEvent) { - const key = getKeyFromInputEvent(evt.nativeEvent as InputEvent); - - if (!IGNORED_META_KEYS.includes(key)) { + function handlePaste(idx: number) { + return function (evt: React.ClipboardEvent) { evt.preventDefault(); - dispatch({type: "handle-key-down", key}); - } + const val = evt.clipboardData.getData("Text"); + debug("main", "event", `paste,idx=${idx},val=${val}`); + dispatch({type: "handle-paste", idx, val}); + }; } - function handlePaste(evt: React.ClipboardEvent) { - evt.preventDefault(); - dispatch({type: "handle-paste", val: evt.clipboardData.getData("Text")}); + function setRefAtIndex(idx: number) { + return function (ref: HTMLInputElement) { + if (ref) { + refs.current[idx] = ref; + } + }; } function hasAutoFocus(idx: number) { @@ -265,19 +321,19 @@ export const PinField: FC = forwardRef((customProps, fwdRef) => { {range(0, codeLength).map(idx => ( ))} diff --git a/src/pin-field/pin-field.types.ts b/src/pin-field/pin-field.types.ts index e7430cf..482493a 100644 --- a/src/pin-field/pin-field.types.ts +++ b/src/pin-field/pin-field.types.ts @@ -2,7 +2,6 @@ export type PinFieldInputProps = Omit; - className: string; length: number; validate: string | string[] | RegExp | ((key: string) => boolean); format: (char: string) => string; @@ -10,7 +9,6 @@ export type PinFieldDefaultProps = { onRejectKey: (key: string, ref?: HTMLInputElement) => any; onChange: (code: string) => void; onComplete: (code: string) => void; - style: React.CSSProperties; }; export type PinFieldProps = Partial & PinFieldInputProps; @@ -23,11 +21,13 @@ export type PinFieldState = { focusIdx: number; codeLength: PinFieldDefaultProps["length"]; isKeyAllowed: (key: string) => boolean; + fallback: {idx: number; val: string} | null; }; export type PinFieldAction = - | {type: "handle-key-down"; key: string} - | {type: "handle-paste"; val: string} + | {type: "handle-key-down"; key: string; idx: number; val: string} + | {type: "handle-key-up"; idx: number; val: string} + | {type: "handle-paste"; idx: number; val: string} | {type: "focus-input"; idx: number}; export type PinFieldEffect = diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..178cd64 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./utils"; diff --git a/src/utils/utils.test.ts b/src/utils/utils.test.ts new file mode 100644 index 0000000..c950c0f --- /dev/null +++ b/src/utils/utils.test.ts @@ -0,0 +1,29 @@ +import {noop, range, omit, debug} from "./utils"; + +test("noop", () => { + expect(noop()).toEqual(undefined); +}); + +test("range", () => { + expect(range(0, 0)).toEqual([]); + expect(range(0, 3)).toEqual([0, 1, 2]); + expect(range(3, 0)).toEqual([]); + expect(range(3, 3)).toEqual([3, 4, 5]); +}); + +test("omit", () => { + expect(omit([], {a: 0, b: 1})).toEqual({a: 0, b: 1}); + expect(omit(["a"], {a: 0, b: 1})).toEqual({b: 1}); + expect(omit(["b"], {a: 0, b: 1})).toEqual({a: 0}); + expect(omit(["a", "b"], {a: 0, b: 1})).toEqual({}); +}); + +test("debug", () => { + jest.spyOn(global.console, "debug").mockImplementation(noop); + + debug("scope", "fn"); + expect(console.debug).toHaveBeenCalledWith(`[React PIN Field] (scope) fn`); + + debug("scope", "fn", "msg"); + expect(console.debug).toHaveBeenCalledWith(`[React PIN Field] (scope) fn: msg`); +}); diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 0000000..ac7000f --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1,23 @@ +export function noop(): void { + // +} + +export function range(start: number, length: number): number[] { + return Array.from({length}, (_, i) => i + start); +} + +export function omit>(keys: string[], input: T): T { + let output: T = Object.create({}); + + for (let key in input) { + if (!keys.includes(key)) { + Object.assign(output, {[key]: input[key]}); + } + } + + return output; +} + +export function debug(scope: string, fn: string, msg?: string): void { + console.debug(`[React PIN Field] (${scope}) ${fn}${msg ? `: ${msg}` : ""}`); +} diff --git a/yarn.lock b/yarn.lock index 1996da2..fd329bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2796,9 +2796,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001111: - version "1.0.30001116" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001116.tgz#f3a3dea347f9294a3bdc4292309039cc84117fb8" - integrity sha512-f2lcYnmAI5Mst9+g0nkMIznFGsArRmZ0qU+dnq8l91hymdc2J3SFbiPhOJEeDqC1vtE8nc1qNQyklzB8veJefQ== + version "1.0.30001199" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001199.tgz" + integrity sha512-ifbK2eChUCFUwGhlEzIoVwzFt1+iriSjyKKFYNfv6hN34483wyWpLLavYQXhnR036LhkdUYaSDpHg1El++VgHQ== capture-exit@^2.0.0: version "2.0.0" @@ -6981,10 +6981,10 @@ node-addon-api@^1.7.1: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.2.tgz#3df30b95720b53c24e59948b49532b662444f54d" integrity sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg== -node-forge@^0.10.0: - version "0.10.0" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" - integrity sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA== +node-forge@^0.7.1: + version "0.7.6" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac" + integrity sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw== node-int64@^0.4.0: version "0.4.0" @@ -7420,7 +7420,7 @@ parcel-bundler@^1.12.4: json5 "^1.0.1" micromatch "^3.0.4" mkdirp "^0.5.1" - node-forge "^0.10.0" + node-forge "^0.7.1" node-libs-browser "^2.0.0" opn "^5.1.0" postcss "^7.0.11"