From 2e08567f37a362e90658c1ae7b221af90e2e37bd Mon Sep 17 00:00:00 2001 From: alexTexis Date: Wed, 20 Dec 2023 20:25:29 -0600 Subject: [PATCH] feat: add color inputs --- jest.setup.ts | 6 + package-lock.json | 192 ++++++++++++++++-- package.json | 1 + src/app/page.tsx | 8 +- .../color-inputs/__tests__/panel.test.tsx | 19 ++ src/components/color-inputs/background.tsx | 20 ++ .../__tests__/color-control.test.tsx | 45 ++++ .../__tests__/color-input.test.tsx | 56 +++++ .../__tests__/popover-color-channels.test.tsx | 67 ++++++ .../__tests__/slider-channel.test.tsx | 105 ++++++++++ .../color-control/__tests__/utils.test.ts | 35 ++++ .../color-control/color-control.tsx | 26 +++ .../color-control/color-input.styled.ts | 25 +++ .../color-control/color-input.tsx | 57 ++++++ .../color-inputs/color-control/index.ts | 3 + .../popover-color-channels.styled.ts | 36 ++++ .../color-control/popover-color-channels.tsx | 113 +++++++++++ .../color-control/slider-channel.styled.ts | 12 ++ .../color-control/slider-channel.tsx | 101 +++++++++ .../color-inputs/color-control/utils.ts | 61 ++++++ src/components/color-inputs/color-inputs.tsx | 28 +++ src/components/color-inputs/foreground.tsx | 12 ++ src/components/color-inputs/index.ts | 1 + src/components/color-inputs/panel.styled.ts | 18 ++ src/components/color-inputs/panel.tsx | 17 ++ src/components/icons/ChangeFill.tsx | 16 ++ src/components/icons/index.ts | 2 + src/hooks/use-controllable.test.ts | 52 +++++ src/hooks/use-controllable.tsx | 28 +++ src/hooks/use-toggle.test.ts | 42 ++++ src/hooks/use-toggle.ts | 21 ++ src/svgs/change_fill.svg | 1 + 32 files changed, 1204 insertions(+), 22 deletions(-) create mode 100644 src/components/color-inputs/__tests__/panel.test.tsx create mode 100644 src/components/color-inputs/background.tsx create mode 100644 src/components/color-inputs/color-control/__tests__/color-control.test.tsx create mode 100644 src/components/color-inputs/color-control/__tests__/color-input.test.tsx create mode 100644 src/components/color-inputs/color-control/__tests__/popover-color-channels.test.tsx create mode 100644 src/components/color-inputs/color-control/__tests__/slider-channel.test.tsx create mode 100644 src/components/color-inputs/color-control/__tests__/utils.test.ts create mode 100644 src/components/color-inputs/color-control/color-control.tsx create mode 100644 src/components/color-inputs/color-control/color-input.styled.ts create mode 100644 src/components/color-inputs/color-control/color-input.tsx create mode 100644 src/components/color-inputs/color-control/index.ts create mode 100644 src/components/color-inputs/color-control/popover-color-channels.styled.ts create mode 100644 src/components/color-inputs/color-control/popover-color-channels.tsx create mode 100644 src/components/color-inputs/color-control/slider-channel.styled.ts create mode 100644 src/components/color-inputs/color-control/slider-channel.tsx create mode 100644 src/components/color-inputs/color-control/utils.ts create mode 100644 src/components/color-inputs/color-inputs.tsx create mode 100644 src/components/color-inputs/foreground.tsx create mode 100644 src/components/color-inputs/index.ts create mode 100644 src/components/color-inputs/panel.styled.ts create mode 100644 src/components/color-inputs/panel.tsx create mode 100644 src/components/icons/ChangeFill.tsx create mode 100644 src/hooks/use-controllable.test.ts create mode 100644 src/hooks/use-controllable.tsx create mode 100644 src/hooks/use-toggle.test.ts create mode 100644 src/hooks/use-toggle.ts create mode 100644 src/svgs/change_fill.svg diff --git a/jest.setup.ts b/jest.setup.ts index d0de870..f764d14 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1 +1,7 @@ import "@testing-library/jest-dom"; + +global.ResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + disconnect: jest.fn(), + unobserve: jest.fn(), +})); diff --git a/package-lock.json b/package-lock.json index 9a8cdf1..a63029d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "color-contrast-inspector", "version": "0.1.0", "dependencies": { + "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-slider": "^1.1.2", "color": "^4.2.3", "next": "14.0.3", @@ -3389,7 +3390,6 @@ "version": "1.5.2", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.2.tgz", "integrity": "sha512-Ii3MrfY/GAIN3OhXNzpCKaLxHQfJF9qvwq/kEJYdqDxeIHa01K8sldugal6TmeeXl+WMvhv9cnVzUTaFFJF09A==", - "dev": true, "dependencies": { "@floating-ui/utils": "^0.1.3" } @@ -3398,7 +3398,6 @@ "version": "1.5.3", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz", "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==", - "dev": true, "dependencies": { "@floating-ui/core": "^1.4.2", "@floating-ui/utils": "^0.1.3" @@ -3408,7 +3407,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.4.tgz", "integrity": "sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==", - "dev": true, "dependencies": { "@floating-ui/dom": "^1.5.1" }, @@ -3420,8 +3418,7 @@ "node_modules/@floating-ui/utils": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", - "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==", - "dev": true + "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", @@ -4746,7 +4743,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz", "integrity": "sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==", - "dev": true, "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-primitive": "1.0.3" @@ -4875,7 +4871,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", - "dev": true, "dependencies": { "@babel/runtime": "^7.13.10" }, @@ -4919,7 +4914,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", - "dev": true, "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-layout-effect": "1.0.1" @@ -4934,6 +4928,150 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.7.tgz", + "integrity": "sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", + "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-escape-keydown": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-scope": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz", + "integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.3.tgz", + "integrity": "sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-use-rect": "1.0.1", + "@radix-ui/react-use-size": "1.0.1", + "@radix-ui/rect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", + "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.2.tgz", @@ -4991,6 +5129,30 @@ } } }, + "node_modules/@radix-ui/react-presence": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-primitive": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", @@ -5290,7 +5452,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", - "dev": true, "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-callback-ref": "1.0.1" @@ -5343,7 +5504,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz", "integrity": "sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==", - "dev": true, "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/rect": "1.0.1" @@ -5404,7 +5564,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.0.1.tgz", "integrity": "sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==", - "dev": true, "dependencies": { "@babel/runtime": "^7.13.10" } @@ -9879,7 +10038,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.3.tgz", "integrity": "sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==", - "dev": true, "dependencies": { "tslib": "^2.0.0" }, @@ -12482,8 +12640,7 @@ "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "dev": true + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" }, "node_modules/detect-package-manager": { "version": "2.0.1", @@ -15116,7 +15273,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "dev": true, "engines": { "node": ">=6" } @@ -16087,7 +16243,6 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dev": true, "dependencies": { "loose-envify": "^1.0.0" } @@ -20558,7 +20713,6 @@ "version": "2.5.5", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", - "dev": true, "dependencies": { "react-remove-scroll-bar": "^2.3.3", "react-style-singleton": "^2.2.1", @@ -20583,7 +20737,6 @@ "version": "2.3.4", "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz", "integrity": "sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==", - "dev": true, "dependencies": { "react-style-singleton": "^2.2.1", "tslib": "^2.0.0" @@ -20605,7 +20758,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "dev": true, "dependencies": { "get-nonce": "^1.0.0", "invariant": "^2.2.4", @@ -23464,7 +23616,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.0.tgz", "integrity": "sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==", - "dev": true, "dependencies": { "tslib": "^2.0.0" }, @@ -23498,7 +23649,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "dev": true, "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" diff --git a/package.json b/package.json index 95f09e6..b8d11a7 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "build:icons": "npx @svgr/cli --config-file .svgrrc src/svgs" }, "dependencies": { + "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-slider": "^1.1.2", "color": "^4.2.3", "next": "14.0.3", diff --git a/src/app/page.tsx b/src/app/page.tsx index c52ab04..8f72b3c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,11 @@ import { ContrastResults } from "@/components/contrast-results"; +import { ColorInputs } from "@/components/color-inputs"; export default function Home() { - return ; + return ( + <> + + + + ); } diff --git a/src/components/color-inputs/__tests__/panel.test.tsx b/src/components/color-inputs/__tests__/panel.test.tsx new file mode 100644 index 0000000..cf3a54e --- /dev/null +++ b/src/components/color-inputs/__tests__/panel.test.tsx @@ -0,0 +1,19 @@ +import { describe, it } from "@jest/globals"; +import { render } from "@testing-library/react"; + +import { Panel } from "../panel"; + +describe("Color Panel", () => { + it("Correct rendering and unmount", () => { + const screen = render(); + + expect(() => screen.unmount()).not.toThrow(); + }); + + it("Should show label and children properties", () => { + const screen = render(Content); + + expect(screen.getByText("Content")).toBeInTheDocument(); + expect(screen.getByText("Background")).toBeInTheDocument(); + }); +}); diff --git a/src/components/color-inputs/background.tsx b/src/components/color-inputs/background.tsx new file mode 100644 index 0000000..d98ddc1 --- /dev/null +++ b/src/components/color-inputs/background.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useState } from "react"; + +import { Panel } from "./panel"; +import { ColorControl, PopoverColorChannels } from "./color-control"; + +export function BackgroundInput() { + const [sourceColor, setSourceColor] = useState("#8FFF00"); + + return ( + + } + sourceColor={sourceColor} + onChange={setSourceColor} + /> + + ); +} diff --git a/src/components/color-inputs/color-control/__tests__/color-control.test.tsx b/src/components/color-inputs/color-control/__tests__/color-control.test.tsx new file mode 100644 index 0000000..707c9a7 --- /dev/null +++ b/src/components/color-inputs/color-control/__tests__/color-control.test.tsx @@ -0,0 +1,45 @@ +import { describe, it } from "@jest/globals"; +import { fireEvent, render } from "@testing-library/react"; +import { useState } from "react"; + +import { ColorControl, PopoverColorChannels } from ".."; + +describe("Color Control", () => { + it("Correct rendering and unmount", () => { + const screen = render(} />); + + expect(() => screen.unmount()).not.toThrow(); + }); + + it("Should update value on change input", () => { + function Render() { + const [value, setValue] = useState("#000"); + + return ( + } + sourceColor={value} + onChange={(value) => setValue(value)} + /> + ); + } + + const screen = render(); + const input = screen.getByRole("textbox"); + + expect(input).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: "#fff" } }); + + expect(input.getAttribute("value")).toBe("#fff"); + }); + + it("Should show popover color channels", () => { + const screen = render(} />); + const buttonSettings = screen.getByLabelText("settings button"); + + fireEvent.click(buttonSettings); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); +}); diff --git a/src/components/color-inputs/color-control/__tests__/color-input.test.tsx b/src/components/color-inputs/color-control/__tests__/color-input.test.tsx new file mode 100644 index 0000000..4b5cd41 --- /dev/null +++ b/src/components/color-inputs/color-control/__tests__/color-input.test.tsx @@ -0,0 +1,56 @@ +import { describe, it } from "@jest/globals"; +import { fireEvent, render } from "@testing-library/react"; +import { Root as PopoverRoot } from "@radix-ui/react-popover"; + +import { ColorInput } from "../color-input"; + +describe("Color Input", () => { + it("Correct rendering and unmount", () => { + const screen = render( + + + , + ); + + expect(() => screen.unmount()).not.toThrow(); + }); + + it("Should show iput value prop", () => { + const screen = render( + + + , + ); + const input = screen.getByRole("textbox") as HTMLInputElement; + + expect(input.value).toBe("#222"); + }); + + it("Should set fallback value on input blur when value is invalid", () => { + const screen = render( + + + , + ); + const input = screen.getByRole("textbox") as HTMLInputElement; + + fireEvent.change(input, { target: { value: ":)" } }); + fireEvent.blur(input); + + expect(input.value).toBe("#000000"); + }); + + it("Should call onChange when input value is valid", () => { + const onChangeMock = jest.fn(); + const screen = render( + + + , + ); + const input = screen.getByRole("textbox") as HTMLInputElement; + + fireEvent.change(input, { target: { value: "#222" } }); + + expect(onChangeMock).toHaveBeenCalled(); + }); +}); diff --git a/src/components/color-inputs/color-control/__tests__/popover-color-channels.test.tsx b/src/components/color-inputs/color-control/__tests__/popover-color-channels.test.tsx new file mode 100644 index 0000000..88b530b --- /dev/null +++ b/src/components/color-inputs/color-control/__tests__/popover-color-channels.test.tsx @@ -0,0 +1,67 @@ +import { describe, it } from "@jest/globals"; +import { fireEvent, render } from "@testing-library/react"; +import * as Popover from "@radix-ui/react-popover"; + +import { PopoverColorChannels } from ".."; + +jest.mock("@radix-ui/react-slider", () => ({ + Root: jest.fn( + ({ + onValueChange, + onValueCommit, + }: { + onValueChange: (value: number[]) => void; + onValueCommit: (value: number[]) => void; + }) => ( + onValueChange([1])} + onMouseUp={() => onValueCommit([1])} + /> + ), + ), + Track: jest.fn(() => ), + Range: jest.fn(() => ), + Thumb: jest.fn(() => ), +})); + +describe("Popover Color Channels", () => { + it("Correct rendering and unmount", () => { + const screen = render( + + + , + ); + + expect(() => screen.unmount()).not.toThrow(); + }); + + it("Should update the active color mode on change select", () => { + const screen = render( + + + , + ); + const select = screen.getByLabelText("select color mode") as HTMLSelectElement; + + fireEvent.change(select, { target: { value: "hsl" } }); + + expect(select.value).toBe("hsl"); + }); + + it("Should call onChange when slider value update", () => { + const onChangeMock = jest.fn(); + + const screen = render( + + + , + ); + const [input] = screen.getAllByRole("slider"); + + fireEvent.change(input, { target: { value: "1" } }); + fireEvent.mouseUp(input); + + expect(onChangeMock).toHaveBeenCalled(); + }); +}); diff --git a/src/components/color-inputs/color-control/__tests__/slider-channel.test.tsx b/src/components/color-inputs/color-control/__tests__/slider-channel.test.tsx new file mode 100644 index 0000000..7db960a --- /dev/null +++ b/src/components/color-inputs/color-control/__tests__/slider-channel.test.tsx @@ -0,0 +1,105 @@ +import { describe, it } from "@jest/globals"; +import { fireEvent, render } from "@testing-library/react"; + +import { SliderChannel } from "../slider-channel"; + +jest.mock("@radix-ui/react-slider", () => ({ + Root: jest.fn( + ({ + onValueChange, + onValueCommit, + }: { + onValueChange: (value: number[]) => void; + onValueCommit: (value: number[]) => void; + }) => ( + onValueChange([1])} + onMouseUp={() => onValueCommit([1])} + /> + ), + ), + Track: jest.fn(() => ), + Range: jest.fn(() => ), + Thumb: jest.fn(() => ), +})); + +describe("Slider Channel", () => { + it("Correct rendering and unmount", () => { + const screen = render(); + + expect(() => screen.unmount()).not.toThrow(); + }); + + it("Should show label prop", () => { + const screen = render(); + + expect(screen.getByText("Background")).toBeInTheDocument(); + }); + + it("Should show value property in the input", () => { + const screen = render(); + const input = screen.getByRole("textbox") as HTMLInputElement; + + expect(input.value).toBe("10"); + }); + + it("Should update input value", () => { + const screen = render(); + const input = screen.getByRole("textbox") as HTMLInputElement; + + expect(input).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: "80" } }); + + expect(input.value).toBe("80"); + }); + + it("Should set to 0 if not meet the minimum and maximum value", () => { + const onChangeMock = jest.fn(); + + const screen = render( + , + ); + const input = screen.getByRole("textbox") as HTMLInputElement; + + fireEvent.change(input, { target: { value: "200" } }); + fireEvent.blur(input); + expect(input.value).toBe("0"); + + fireEvent.change(input, { target: { value: "-200" } }); + fireEvent.blur(input); + expect(input.value).toBe("0"); + + expect(onChangeMock).toHaveBeenCalled(); + }); + + it("Should fix input if value start with 0", () => { + const screen = render(); + const input = screen.getByRole("textbox") as HTMLInputElement; + + fireEvent.change(input, { target: { value: "002" } }); + fireEvent.blur(input); + + expect(input.value).toBe("2"); + }); + + it("Should call on SliderValueChange and onSliderValueCommit on slider value change", () => { + const onSliderValueChangeMock = jest.fn(); + const onSliderValueCommitMock = jest.fn(); + const screen = render( + , + ); + const input = screen.getByRole("slider") as HTMLInputElement; + + fireEvent.change(input, { target: { value: "1" } }); + fireEvent.mouseUp(input); + + expect(onSliderValueChangeMock).toHaveBeenCalled(); + expect(onSliderValueCommitMock).toHaveBeenCalled(); + }); +}); diff --git a/src/components/color-inputs/color-control/__tests__/utils.test.ts b/src/components/color-inputs/color-control/__tests__/utils.test.ts new file mode 100644 index 0000000..636906b --- /dev/null +++ b/src/components/color-inputs/color-control/__tests__/utils.test.ts @@ -0,0 +1,35 @@ +import { describe, it } from "@jest/globals"; + +import { colorModes } from "../utils"; + +describe("Color Control/Utils", () => { + it("Should convert to rgb channels", () => { + const channels = colorModes.rgb.converter("#fff"); + + expect(channels).toMatchObject([255, 255, 255]); + }); + + it("Should convert to hsl channels", () => { + const channels = colorModes.hsl.converter("#fff"); + + expect(channels).toMatchObject([0, 0, 100]); + }); + + it("Should convert to cmyk channels", () => { + const channels = colorModes.cmyk.converter("#fff"); + + expect(channels).toMatchObject([0, 0, 0, 0]); + }); + + it("Should convert to hsv channels", () => { + const channels = colorModes.hsv.converter("#fff"); + + expect(channels).toMatchObject([0, 0, 100]); + }); + + it("Should convert to hwb channels", () => { + const channels = colorModes.hwb.converter("#fff"); + + expect(channels).toMatchObject([0, 100, 0]); + }); +}); diff --git a/src/components/color-inputs/color-control/color-control.tsx b/src/components/color-inputs/color-control/color-control.tsx new file mode 100644 index 0000000..3644e8c --- /dev/null +++ b/src/components/color-inputs/color-control/color-control.tsx @@ -0,0 +1,26 @@ +"use client"; + +import type { ReactNode } from "react"; + +import * as Popover from "@radix-ui/react-popover"; + +import { useToggle } from "@/hooks/use-toggle"; + +import { ColorInput } from "./color-input"; + +interface Iprops { + popover: ReactNode; + sourceColor?: string; + onChange?: (color: string) => void; +} + +export function ColorControl({ popover, sourceColor, onChange }: Iprops) { + const { isEnabled: isOpen, setOpen } = useToggle(); + + return ( + + + {isOpen ? popover : null} + + ); +} diff --git a/src/components/color-inputs/color-control/color-input.styled.ts b/src/components/color-inputs/color-control/color-input.styled.ts new file mode 100644 index 0000000..eebc225 --- /dev/null +++ b/src/components/color-inputs/color-control/color-input.styled.ts @@ -0,0 +1,25 @@ +import { css } from "@root/styled-system/css"; + +export default { + colorPreview: css({ + w: "6", + h: "6", + display: "inline-block", + border: "1px solid", + borderColor: "border-secondary", + rounded: "999px", + shadow: "xs", + }), + + settings: css({ + color: "fg-primary", + borderLeft: "1px solid", + borderColor: "border-secondary", + pl: "4", + my: "2", + display: "inline-block", + cursor: "pointer", + }), + + icon: css({ fontSize: "icon-20" }), +}; diff --git a/src/components/color-inputs/color-control/color-input.tsx b/src/components/color-inputs/color-control/color-input.tsx new file mode 100644 index 0000000..5e2e54a --- /dev/null +++ b/src/components/color-inputs/color-control/color-input.tsx @@ -0,0 +1,57 @@ +"use client"; + +import type { ChangeEvent } from "react"; + +import * as Popover from "@radix-ui/react-popover"; + +import { SettingsFill } from "@/components/icons"; +import { Input } from "@/components/primitives/input"; +import { InputGroup } from "@/components/primitives/input-group"; +import { useControllable } from "@/hooks/use-controllable"; + +import { regexColorHexadecimal } from "./utils"; +import classes from "./color-input.styled"; + +interface Iprops { + value?: string; + onChange?: (color: string) => void; +} + +const fallback = "#000000"; + +export function ColorInput({ value: valueProp, onChange }: Iprops) { + const [value, setValue] = useControllable(fallback, valueProp, onChange); + + function handleChange(e: ChangeEvent) { + const nextValue = e.target.value; + + const omitOnChange = !regexColorHexadecimal.test(nextValue); + + setValue(nextValue, omitOnChange); + } + + function handleBlur() { + if (!value || !regexColorHexadecimal.test(value)) setValue(fallback); + } + + return ( + + + + } + startContent={} + > + + + ); +} diff --git a/src/components/color-inputs/color-control/index.ts b/src/components/color-inputs/color-control/index.ts new file mode 100644 index 0000000..b46041a --- /dev/null +++ b/src/components/color-inputs/color-control/index.ts @@ -0,0 +1,3 @@ +export { ColorControl } from "./color-control"; + +export { PopoverColorChannels } from "./popover-color-channels"; diff --git a/src/components/color-inputs/color-control/popover-color-channels.styled.ts b/src/components/color-inputs/color-control/popover-color-channels.styled.ts new file mode 100644 index 0000000..eca0df4 --- /dev/null +++ b/src/components/color-inputs/color-control/popover-color-channels.styled.ts @@ -0,0 +1,36 @@ +import { css } from "@root/styled-system/css"; + +export default { + popoverContent: css({ + backgroundColor: "bg-primary", + shadow: "2xl", + rounded: "lg", + border: "1px solid", + borderColor: "border-secondary", + width: "64", + overflow: "hidden", + }), + + colorModes: css({ + position: "relative", + borderBottom: "1px solid", + borderColor: "border-secondary", + px: "5", + py: "3", + display: "flex", + justifyContent: "space-between", + }), + + channels: css({ display: "grid", gap: "6", px: "5", py: "6" }), + + select: css({ outline: "none", textTransform: "capitalize" }), + + channelActive: css({ borderColor: "bg-primary-solid!" }), + + preview: css({ + h: "8", + w: "8", + rounded: "999px", + mr: "1", + }), +}; diff --git a/src/components/color-inputs/color-control/popover-color-channels.tsx b/src/components/color-inputs/color-control/popover-color-channels.tsx new file mode 100644 index 0000000..6a23e9d --- /dev/null +++ b/src/components/color-inputs/color-control/popover-color-channels.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState, useMemo, useRef } from "react"; +import * as Popover from "@radix-ui/react-popover"; +import Color from "color"; + +import { useControllable } from "@/hooks/use-controllable"; + +import { SliderChannel } from "./slider-channel"; +import classes from "./popover-color-channels.styled"; +import { colorModes, type ColorMode } from "./utils"; + +interface Iprops { + sourceColor?: string; + onChange?: (value: string) => void; +} + +export function PopoverColorChannels({ sourceColor, onChange }: Iprops) { + const [color, setColor] = useControllable("#000", sourceColor, onChange); + const [mode, setMode] = useState("rgb"); + const modesList = useMemo(() => Object.keys(colorModes), []); + const colorPreviewRef = useRef(null); + const channels = useMemo( + () => colorModes[mode].converter(color).map((v) => Math.floor(v)), + [mode, color], + ); + + function updateChannel( + channels: number[], + channelMofified: number, + channelIndexModified: number, + ) { + const nextChannels = [...channels]; + + nextChannels[channelIndexModified] = channelMofified; + + return nextChannels; + } + + const handleChannelChange = (index: number) => (value: number | string) => { + const nextValue = parseInt(value.toString()); + const nextChannels = updateChannel(channels, nextValue, index); + const resolvedColor = Color(nextChannels, mode).hex(); + + setColor(resolvedColor); + }; + + function handleOnClickColorMode(name: keyof ColorMode) { + setMode(name); + } + + const handleChangeSliderValue = (index: number) => (channelValue: number) => { + const nextChannels = updateChannel(channels, channelValue, index); + const resolvedColor = Color(nextChannels, mode).hex(); + + updateColorPreview(resolvedColor); + }; + + function updateColorPreview(value: string) { + if (colorPreviewRef.current) colorPreviewRef.current.style.backgroundColor = value; + } + + return ( + + +
+
+ + +
+
+ {channels.map((value, idx) => { + const colorMode = colorModes[mode]; + + return ( + + ); + })} +
+
+
+
+ ); +} diff --git a/src/components/color-inputs/color-control/slider-channel.styled.ts b/src/components/color-inputs/color-control/slider-channel.styled.ts new file mode 100644 index 0000000..4c7adae --- /dev/null +++ b/src/components/color-inputs/color-control/slider-channel.styled.ts @@ -0,0 +1,12 @@ +import { slider as sliderRecipe } from "@root/styled-system/recipes"; +import { css } from "@root/styled-system/css"; + +export default { + header: css({ display: "flex", justifyContent: "space-between", alignItems: "center", mb: "5" }), + + headerLabel: css({ fontWeight: "bold" }), + + input: css({ width: "16", textAlign: "center" }), + + slider: sliderRecipe(), +}; diff --git a/src/components/color-inputs/color-control/slider-channel.tsx b/src/components/color-inputs/color-control/slider-channel.tsx new file mode 100644 index 0000000..ab24522 --- /dev/null +++ b/src/components/color-inputs/color-control/slider-channel.tsx @@ -0,0 +1,101 @@ +"use client"; + +import type { ChangeEvent } from "react"; + +import * as Slider from "@radix-ui/react-slider"; + +import { Input } from "@/components/primitives/input"; +import { useControllable } from "@/hooks/use-controllable"; + +import classes from "./slider-channel.styled"; +import { regexNumberText, regexStartWithZero } from "./utils"; + +interface IsliderChannelProps { + label: string; + onChange?: (value: string) => void; + onSliderValueCommit?: (value: number) => void; + onSliderValueChange?: (value: number) => void; + step?: number; + max?: number; + min?: number; + value?: number; +} + +export function SliderChannel({ + label, + onChange, + onSliderValueChange, + onSliderValueCommit, + max = 100, + min = 0, + step = 1, + value, +}: IsliderChannelProps) { + const valueToString = value ? value.toString() : undefined; + const [inputValue, setInputValue] = useControllable("0", valueToString, onChange); + const [sliderValue, setSliderValue] = useControllable(0, value, onSliderValueChange); + + function validateInputOnBlur() { + let nextValue = inputValue; + + if (regexStartWithZero.test(nextValue)) { + nextValue = parseInt(inputValue).toString(); + } + + if ( + !regexNumberText.test(inputValue) || + parseInt(nextValue) < min || + parseInt(nextValue) > max + ) { + nextValue = "0"; + } + + setSliderValue(parseInt(nextValue)); + setInputValue(nextValue); + } + + function handleInputChange(e: ChangeEvent) { + const nextValue = e.target.value; + + setInputValue(nextValue, true); + } + + function handleSliderChange([value]: number[]) { + setInputValue(value.toString(), true); + setSliderValue(value); + } + + function handleSliderCommit([value]: number[]) { + onSliderValueCommit?.(value); + } + + return ( +
+
+ {label} + +
+ + + + + + +
+ ); +} diff --git a/src/components/color-inputs/color-control/utils.ts b/src/components/color-inputs/color-control/utils.ts new file mode 100644 index 0000000..3f9528c --- /dev/null +++ b/src/components/color-inputs/color-control/utils.ts @@ -0,0 +1,61 @@ +import type * as colorConvert from "color-convert"; + +import Color from "color"; + +export const regexColorHexadecimal = new RegExp(/^#(?:[0-9a-fA-F]{3}){1,2}$/); + +export const regexStartWithZero = new RegExp(/^0.*/); + +export const regexNumberText = new RegExp(/^[0-9]+$/); + +export type ColorMode = Pick; + +export const colorModes: Record< + keyof ColorMode, + { + channels: { label: string; min: number; max: number }[]; + converter: (color: string) => number[]; + } +> = { + rgb: { + channels: [ + { label: "Red", min: 0, max: 255 }, + { label: "Green", min: 0, max: 255 }, + { label: "Blue", min: 0, max: 255 }, + ], + converter: (color) => Color(color).rgb().array(), + }, + hsl: { + channels: [ + { label: "Hue", min: 0, max: 360 }, + { label: "Saturation", min: 0, max: 100 }, + { label: "Lightness", min: 0, max: 100 }, + ], + converter: (color) => Color(color).hsl().array(), + }, + cmyk: { + channels: [ + { label: "Cyan", min: 0, max: 100 }, + { label: "Magenta", min: 0, max: 100 }, + { label: "Yellow", min: 0, max: 100 }, + { label: "Black", min: 0, max: 100 }, + ], + converter: (color) => Color(color).cmyk().array(), + }, + hsv: { + channels: [ + { label: "Hue", min: 0, max: 360 }, + { label: "Saturation", min: 0, max: 100 }, + { label: "Value", min: 0, max: 100 }, + ], + converter: (color) => Color(color).hsv().array(), + }, + hwb: { + channels: [ + { label: "Hue", min: 0, max: 360 }, + { label: "Whiteness", min: 0, max: 100 }, + { label: "Blackness", min: 0, max: 100 }, + ], + converter: (color) => Color(color).hwb().array(), + }, +}; diff --git a/src/components/color-inputs/color-inputs.tsx b/src/components/color-inputs/color-inputs.tsx new file mode 100644 index 0000000..81b559a --- /dev/null +++ b/src/components/color-inputs/color-inputs.tsx @@ -0,0 +1,28 @@ +import { css } from "@root/styled-system/css"; + +import { Button } from "@/components/primitives/button"; +import { ChangeFill } from "@/components/icons"; + +import { BackgroundInput } from "./background"; +import { ForegroundInput } from "./foreground"; + +export function ColorInputs() { + return ( +
+ + + +
+ ); +} diff --git a/src/components/color-inputs/foreground.tsx b/src/components/color-inputs/foreground.tsx new file mode 100644 index 0000000..8ca24eb --- /dev/null +++ b/src/components/color-inputs/foreground.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { Panel } from "./panel"; +import { ColorControl, PopoverColorChannels } from "./color-control"; + +export function ForegroundInput() { + return ( + + } /> + + ); +} diff --git a/src/components/color-inputs/index.ts b/src/components/color-inputs/index.ts new file mode 100644 index 0000000..8ecf58d --- /dev/null +++ b/src/components/color-inputs/index.ts @@ -0,0 +1 @@ +export { ColorInputs } from "./color-inputs"; diff --git a/src/components/color-inputs/panel.styled.ts b/src/components/color-inputs/panel.styled.ts new file mode 100644 index 0000000..bdfff9a --- /dev/null +++ b/src/components/color-inputs/panel.styled.ts @@ -0,0 +1,18 @@ +import { css } from "@root/styled-system/css"; + +export default { + root: css({ backgroundColor: "bg-primary", rounded: "xl", shadow: "xs", flex: 1, width: "100%" }), + + header: css({ + color: "text-primary", + py: "4", + px: "5", + borderBottomWidth: "1px", + borderStyle: "solid", + borderColor: "border-secondary", + textAlign: "center", + fontWeight: "bold", + }), + + body: css({ px: "5", py: "8" }), +}; diff --git a/src/components/color-inputs/panel.tsx b/src/components/color-inputs/panel.tsx new file mode 100644 index 0000000..a2130c7 --- /dev/null +++ b/src/components/color-inputs/panel.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; + +import classes from "./panel.styled"; + +interface Iprops { + label: string; + children?: ReactNode; +} + +export function Panel({ label, children }: Iprops) { + return ( +
+
{label}
+
{children}
+
+ ); +} diff --git a/src/components/icons/ChangeFill.tsx b/src/components/icons/ChangeFill.tsx new file mode 100644 index 0000000..1a9f445 --- /dev/null +++ b/src/components/icons/ChangeFill.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from "react"; +function SvgChangeFill(props: SVGProps) { + return ( + + + + + + + ); +} + +export default SvgChangeFill; diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts index cf7f145..ea59176 100644 --- a/src/components/icons/index.ts +++ b/src/components/icons/index.ts @@ -1,3 +1,5 @@ +export { default as ChangeFill } from "./ChangeFill"; + export { default as CheckFill } from "./CheckFill"; export { default as CloseFill } from "./CloseFill"; diff --git a/src/hooks/use-controllable.test.ts b/src/hooks/use-controllable.test.ts new file mode 100644 index 0000000..ad35041 --- /dev/null +++ b/src/hooks/use-controllable.test.ts @@ -0,0 +1,52 @@ +import { describe, it } from "@jest/globals"; +import { renderHook, act } from "@testing-library/react"; + +import { useControllable } from "./use-controllable"; + +describe("Hooks / useControllable", () => { + it("unconstrollabe", () => { + const { result } = renderHook(() => useControllable(1)); + const [value, setValue] = result.current; + + expect(value).toBe(1); + + act(() => setValue(2)); + expect(result.current[0]).toBe(2); + }); + + it("call setState passing function", () => { + const { result } = renderHook(() => useControllable(1)); + const [value, setValue] = result.current; + + expect(value).toBe(1); + + act(() => setValue((current) => current + 1)); + expect(result.current[0]).toBe(2); + }); + + it("constrollabe", () => { + const handlerMock = jest.fn(); + const controlledValue = 2; + const { result } = renderHook(() => useControllable(1, controlledValue, handlerMock)); + const [value, setValue] = result.current; + + expect(value).toBe(2); + + act(() => setValue(10)); + expect(result.current[0]).toBe(10); + expect(handlerMock).toHaveBeenCalled(); + }); + + it("constrollabe without call onChange handler", () => { + const handlerMock = jest.fn(); + const controlledValue = 2; + const { result } = renderHook(() => useControllable(1, controlledValue, handlerMock)); + const [value, setValue] = result.current; + + expect(value).toBe(2); + + act(() => setValue(10, true)); + expect(result.current[0]).toBe(10); + expect(handlerMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/use-controllable.tsx b/src/hooks/use-controllable.tsx new file mode 100644 index 0000000..1a2a5c5 --- /dev/null +++ b/src/hooks/use-controllable.tsx @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react"; + +type NextValueFn = (value: T) => T; +type SetValueFn = (value: NextValueFn | T, omitOnChange?: boolean) => void; + +export function useControllable( + defaultValue: T, + controlledValue?: T, + onChange?: (nextValue: T) => void, +): [T, SetValueFn] { + const isControlled = controlledValue !== undefined; + const initialValue = !isControlled ? defaultValue : controlledValue; + const [value, setValue] = useState(initialValue); + + const setStateValue: SetValueFn = (nextValue, omitOnChange = false) => { + const nextValueIsFn = typeof nextValue === "function"; + const newValue = nextValueIsFn ? (nextValue as NextValueFn)(value) : nextValue; + + setValue(newValue); + if (!omitOnChange) onChange?.(newValue); + }; + + useEffect(() => { + if (isControlled) setValue(controlledValue); + }, [controlledValue, isControlled, setValue]); + + return [value, setStateValue]; +} diff --git a/src/hooks/use-toggle.test.ts b/src/hooks/use-toggle.test.ts new file mode 100644 index 0000000..0a951f2 --- /dev/null +++ b/src/hooks/use-toggle.test.ts @@ -0,0 +1,42 @@ +import { describe, it } from "@jest/globals"; +import { renderHook, act } from "@testing-library/react"; + +import { useToggle } from "./use-toggle"; + +describe("Hooks/use toggle", () => { + it("Should init with inital value prop", () => { + const { + result: { current }, + } = renderHook(() => useToggle(true)); + + expect(current.isEnabled).toBeTruthy(); + }); + + it("Should update value to 'true' on call onOpen", () => { + const { result } = renderHook(() => useToggle()); + + act(() => result.current.onOpen()); + expect(result.current.isEnabled).toBeTruthy(); + }); + + it("Should update value to 'false' on call onClose", () => { + const { result } = renderHook(() => useToggle(true)); + + act(() => result.current.onClose()); + expect(result.current.isEnabled).toBeFalsy(); + }); + + it("Should update value on call onToggle", () => { + const { result } = renderHook(() => useToggle()); + + act(() => result.current.onToggle()); + expect(result.current.isEnabled).toBeTruthy(); + }); + + it("Should set value on call setOpen", () => { + const { result } = renderHook(() => useToggle()); + + act(() => result.current.setOpen(true)); + expect(result.current.isEnabled).toBeTruthy(); + }); +}); diff --git a/src/hooks/use-toggle.ts b/src/hooks/use-toggle.ts new file mode 100644 index 0000000..0ce7fbf --- /dev/null +++ b/src/hooks/use-toggle.ts @@ -0,0 +1,21 @@ +import { useState } from "react"; + +export function useToggle(initialValue = false) { + const [isEnabled, setIsEnabled] = useState(initialValue); + + const onToggle = () => setIsEnabled((show) => !show); + + const onOpen = () => setIsEnabled(true); + + const onClose = () => setIsEnabled(false); + + const setOpen = (value: boolean) => setIsEnabled(value); + + return { + isEnabled, + onToggle, + onOpen, + onClose, + setOpen, + }; +} diff --git a/src/svgs/change_fill.svg b/src/svgs/change_fill.svg new file mode 100644 index 0000000..2b8d2fe --- /dev/null +++ b/src/svgs/change_fill.svg @@ -0,0 +1 @@ + \ No newline at end of file