From f015d73ba3464e4234879f0178e86be8ac2a2bb9 Mon Sep 17 00:00:00 2001 From: prabhuignoto Date: Wed, 29 Jan 2025 21:45:31 +0530 Subject: [PATCH 1/5] chore: update ESLint configuration and improve code structure --- .eslintignore | 3 - .eslintrc.js | 45 --- .gitignore | 4 + eslint.config.mjs | 73 +++++ package.json | 8 +- pnpm-lock.yaml | 268 ++++++++++++------ .../__tests__/useClickOutside.test.tsx | 69 +++-- .../effects/__tests__/useMatchMedia.test.tsx | 168 +++++++++-- .../effects/useCloseClickOutside.ts | 44 ++- src/components/effects/useMatchMedia.ts | 125 +++++--- src/components/effects/useSlideshow.ts | 117 +++++--- src/components/elements/list/list-item.tsx | 33 ++- src/components/elements/list/list.styles.ts | 88 +++--- src/components/elements/list/list.tsx | 122 ++++---- .../popover/__tests__/popover.test.tsx | 2 +- src/components/elements/popover/index.tsx | 112 +++++--- .../elements/popover/popover.styles.ts | 66 +++-- .../timeline/timeline-popover-elements.tsx | 2 +- src/components/timeline/timeline.style.ts | 2 + .../toolbar/__tests__/toolbar.test.tsx | 12 +- src/components/toolbar/index.tsx | 72 +++-- src/components/toolbar/toolbar.styles.ts | 28 +- 22 files changed, 985 insertions(+), 478 deletions(-) delete mode 100644 .eslintignore delete mode 100644 .eslintrc.js create mode 100644 eslint.config.mjs diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 2b21d550..00000000 --- a/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -src/demo/* -src/assets/* -src/examples/* \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 911fddfa..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,45 +0,0 @@ -module.exports = { - env: { - browser: true, - es2023: true, - }, - extends: [ - 'plugin:import/typescript', - 'plugin:react/recommended', - 'prettier', - 'plugin:react/jsx-runtime', - // 'eslint:recommended', - ], - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - ecmaVersion: 12, - sourceType: 'module', - }, - plugins: [ - 'import', - 'react', - '@typescript-eslint', - 'jsx-a11y', - 'typescript-sort-keys', - 'sort-keys-fix', - ], - rules: { - // 'import/no-unused-modules': [ - // 1, - // { - // unusedExports: true, - // }, - // ], - 'sort-keys-fix/sort-keys-fix': 'error', - 'typescript-sort-keys/interface': 'error', - 'typescript-sort-keys/string-enum': 'error', - }, - settings: { - react: { - version: '18.1.0', - }, - }, -}; diff --git a/.gitignore b/.gitignore index 2e3870a7..25095180 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,7 @@ react-test-bed # Ignore Gradle build output directory build + +## Panda +styled-system +styled-system-studio \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..d37fd093 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,73 @@ +import { fixupConfigRules, fixupPluginRules } from '@eslint/compat'; +import _import from 'eslint-plugin-import'; +import react from 'eslint-plugin-react'; +import typescriptEslint from '@typescript-eslint/eslint-plugin'; +import jsxA11Y from 'eslint-plugin-jsx-a11y'; +import typescriptSortKeys from 'eslint-plugin-typescript-sort-keys'; +import sortKeysFix from 'eslint-plugin-sort-keys-fix'; +import globals from 'globals'; +import tsParser from '@typescript-eslint/parser'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import js from '@eslint/js'; +import { FlatCompat } from '@eslint/eslintrc'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + allConfig: js.configs.all, + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}); + +export default [ + { + ignores: ['src/demo/*', 'src/assets/*', 'src/examples/*'], + }, + ...fixupConfigRules( + compat.extends( + 'plugin:import/typescript', + 'plugin:react/recommended', + 'prettier', + 'plugin:react/jsx-runtime', + ), + ), + { + languageOptions: { + ecmaVersion: 12, + + globals: { + ...globals.browser, + }, + parser: tsParser, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + + sourceType: 'module', + }, + + plugins: { + '@typescript-eslint': typescriptEslint, + import: fixupPluginRules(_import), + 'jsx-a11y': jsxA11Y, + react: fixupPluginRules(react), + 'sort-keys-fix': sortKeysFix, + 'typescript-sort-keys': typescriptSortKeys, + }, + + rules: { + 'sort-keys-fix/sort-keys-fix': 'error', + 'typescript-sort-keys/interface': 'error', + 'typescript-sort-keys/string-enum': 'error', + }, + + settings: { + react: { + version: '18.1.0', + }, + }, + }, +]; diff --git a/package.json b/package.json index 3380333f..06c67c4f 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "cypress:runner": "start-server-and-test start http://localhost:4444 cypress:record", "cypress:test": "start-server-and-test start http://localhost:4444 cypress", "cypress:quiet": "cypress run --quiet --headless", - "eslint": "eslint src/**/*.{tsx,ts}", - "fix-js": "eslint src/**/*.{tsx,ts} --fix", + "eslint": "eslint src/**/*.{tsx,ts} --no-warn-ignored", + "fix-js": "eslint src/**/*.{tsx,ts} --fix --no-warn-ignored", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", "lint": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"", "lint:all": "pnpm eslint && pnpm lint:css && pnpm lint", @@ -67,6 +67,9 @@ "@babel/preset-react": "^7.26.3", "@babel/preset-typescript": "^7.26.0", "@emotion/babel-plugin": "^11.13.5", + "@eslint/compat": "^1.2.5", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.19.0", "@jest/types": "^29.6.3", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-buble": "^1.0.3", @@ -108,6 +111,7 @@ "eslint-plugin-react": "^7.37.4", "eslint-plugin-sort-keys-fix": "^1.1.2", "eslint-plugin-typescript-sort-keys": "^3.3.0", + "globals": "^15.14.0", "husky": "^9.1.7", "intersection-observer": "^0.12.2", "jsdom": "^26.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d0fb52c..e432ec67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,15 @@ importers: '@emotion/babel-plugin': specifier: ^11.13.5 version: 11.13.5 + '@eslint/compat': + specifier: ^1.2.5 + version: 1.2.5(eslint@9.18.0(jiti@2.4.1)) + '@eslint/eslintrc': + specifier: ^3.2.0 + version: 3.2.0 + '@eslint/js': + specifier: ^9.19.0 + version: 9.19.0 '@jest/types': specifier: ^29.6.3 version: 29.6.3 @@ -136,7 +145,7 @@ importers: version: 8.20.0(eslint@9.18.0(jiti@2.4.1))(typescript@5.7.3) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.3.4(vite@6.0.7(@types/node@22.10.6)(jiti@2.4.1)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)(yaml@2.6.1)) + version: 4.3.4(vite@6.0.7(@types/node@22.10.6)(jiti@2.4.1)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)(yaml@2.6.1)) '@vitest/coverage-v8': specifier: ^2.1.8 version: 2.1.8(vitest@2.1.8) @@ -185,6 +194,9 @@ importers: eslint-plugin-typescript-sort-keys: specifier: ^3.3.0 version: 3.3.0(@typescript-eslint/parser@8.20.0(eslint@9.18.0(jiti@2.4.1))(typescript@5.7.3))(eslint@9.18.0(jiti@2.4.1))(typescript@5.7.3) + globals: + specifier: ^15.14.0 + version: 15.14.0 husky: specifier: ^9.1.7 version: 9.1.7 @@ -253,7 +265,7 @@ importers: version: 1.83.4 semver: specifier: 7.5.4 - version: 7.6.3 + version: 7.5.4 size-limit: specifier: ^11.1.6 version: 11.1.6 @@ -295,13 +307,13 @@ importers: version: 3.0.0(typescript@5.7.3) vite: specifier: ^6.0.7 - version: 6.0.7(@types/node@22.10.6)(jiti@2.4.1)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)(yaml@2.6.1) + version: 6.0.7(@types/node@22.10.6)(jiti@2.4.1)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)(yaml@2.6.1) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.7.3)(vite@6.0.7(@types/node@22.10.6)(jiti@2.4.1)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)(yaml@2.6.1)) + version: 5.1.4(typescript@5.7.3)(vite@6.0.7(@types/node@22.10.6)(jiti@2.4.1)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)(yaml@2.6.1)) vitest: specifier: ^2.1.8 - version: 2.1.8(@types/node@22.10.6)(@vitest/ui@2.1.8)(jsdom@26.0.0)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0) + version: 2.1.8(@types/node@22.10.6)(@vitest/ui@2.1.8)(jsdom@26.0.0)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0) packages: @@ -1567,6 +1579,15 @@ packages: resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@eslint/compat@1.2.5': + resolution: {integrity: sha512-5iuG/StT+7OfvhoBHPlmxkPA9om6aDUFgmD4+mWKAGsYt4vCe8rypneG03AuseyRHBmcCLXQtIH5S26tIoggLg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.10.0 + peerDependenciesMeta: + eslint: + optional: true + '@eslint/config-array@0.19.1': resolution: {integrity: sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1591,6 +1612,10 @@ packages: resolution: {integrity: sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@9.19.0': + resolution: {integrity: sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@2.1.5': resolution: {integrity: sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3307,15 +3332,6 @@ packages: supports-color: optional: true - debug@4.3.5: - resolution: {integrity: sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -4103,6 +4119,10 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globals@15.14.0: + resolution: {integrity: sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==} + engines: {node: '>=18'} + globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} @@ -4769,6 +4789,64 @@ packages: lie@3.1.1: resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} + lightningcss-darwin-arm64@1.25.1: + resolution: {integrity: sha512-G4Dcvv85bs5NLENcu/s1f7ehzE3D5ThnlWSDwE190tWXRQCQaqwcuHe+MGSVI/slm0XrxnaayXY+cNl3cSricw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.25.1: + resolution: {integrity: sha512-dYWuCzzfqRueDSmto6YU5SoGHvZTMU1Em9xvhcdROpmtOQLorurUZz8+xFxZ51lCO2LnYbfdjZ/gCqWEkwixNg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.25.1: + resolution: {integrity: sha512-hXoy2s9A3KVNAIoKz+Fp6bNeY+h9c3tkcx1J3+pS48CqAt+5bI/R/YY4hxGL57fWAIquRjGKW50arltD6iRt/w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.25.1: + resolution: {integrity: sha512-tWyMgHFlHlp1e5iW3EpqvH5MvsgoN7ZkylBbG2R2LWxnvH3FuWCJOhtGcYx9Ks0Kv0eZOBud789odkYLhyf1ng==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.25.1: + resolution: {integrity: sha512-Xjxsx286OT9/XSnVLIsFEDyDipqe4BcLeB4pXQ/FEA5+2uWCCuAEarUNQumRucnj7k6ftkAHUEph5r821KBccQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.25.1: + resolution: {integrity: sha512-IhxVFJoTW8wq6yLvxdPvyHv4NjzcpN1B7gjxrY3uaykQNXPHNIpChLB52+wfH+yS58zm1PL4LemUp8u9Cfp6Bw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.25.1: + resolution: {integrity: sha512-RXIaru79KrREPEd6WLXfKfIp4QzoppZvD3x7vuTKkDA64PwTzKJ2jaC43RZHRt8BmyIkRRlmywNhTRMbmkPYpA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.25.1: + resolution: {integrity: sha512-TdcNqFsAENEEFr8fJWg0Y4fZ/nwuqTRsIr7W7t2wmDUlA8eSXVepeeONYcb+gtTj1RaXn/WgNLB45SFkz+XBZA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-x64-msvc@1.25.1: + resolution: {integrity: sha512-9KZZkmmy9oGDSrnyHuxP6iMhbsgChUiu/NSgOx+U1I/wTngBStDf2i2aGRCHvFqj19HqqBEI4WuGVQBa2V6e0A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.25.1: + resolution: {integrity: sha512-V0RMVZzK1+rCHpymRv4URK2lNhIRyO8g7U7zOFwVAhJuat74HtkjIQpQRKNCwFEYkRGpafOpmXXLoaoBcyVtBg==} + engines: {node: '>= 12.0.0'} + lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} @@ -4868,10 +4946,6 @@ packages: resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==} engines: {node: 14 || >=16.14} - lru-cache@11.0.0: - resolution: {integrity: sha512-Qv32eSV1RSCfhY3fpPE2GNZ8jgM9X7rdAfemLWqTUxwiyIC4jJ6Sy0fZ8H+oLWevO6i4/bizg7c8d8i6bxrzbA==} - engines: {node: 20 || >=22} - lru-cache@11.0.2: resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==} engines: {node: 20 || >=22} @@ -4972,10 +5046,6 @@ packages: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} engines: {node: '>=8.6'} - micromatch@4.0.7: - resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} - engines: {node: '>=8.6'} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -6465,11 +6535,6 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.6.3: - resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} - engines: {node: '>=10'} - hasBin: true - serialize-error@7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} @@ -8868,6 +8933,10 @@ snapshots: '@eslint-community/regexpp@4.12.1': {} + '@eslint/compat@1.2.5(eslint@9.18.0(jiti@2.4.1))': + optionalDependencies: + eslint: 9.18.0(jiti@2.4.1) + '@eslint/config-array@0.19.1': dependencies: '@eslint/object-schema': 2.1.5 @@ -8912,6 +8981,8 @@ snapshots: '@eslint/js@9.18.0': {} + '@eslint/js@9.19.0': {} + '@eslint/object-schema@2.1.5': {} '@eslint/plugin-kit@0.2.5': @@ -9756,14 +9827,14 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitejs/plugin-react@4.3.4(vite@6.0.7(@types/node@22.10.6)(jiti@2.4.1)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)(yaml@2.6.1))': + '@vitejs/plugin-react@4.3.4(vite@6.0.7(@types/node@22.10.6)(jiti@2.4.1)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)(yaml@2.6.1))': dependencies: '@babel/core': 7.26.0 '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.0) '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.0) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 - vite: 6.0.7(@types/node@22.10.6)(jiti@2.4.1)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)(yaml@2.6.1) + vite: 6.0.7(@types/node@22.10.6)(jiti@2.4.1)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)(yaml@2.6.1) transitivePeerDependencies: - supports-color @@ -9781,7 +9852,7 @@ snapshots: std-env: 3.8.0 test-exclude: 7.0.1 tinyrainbow: 1.2.0 - vitest: 2.1.8(@types/node@22.10.6)(@vitest/ui@2.1.8)(jsdom@26.0.0)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0) + vitest: 2.1.8(@types/node@22.10.6)(@vitest/ui@2.1.8)(jsdom@26.0.0)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0) transitivePeerDependencies: - supports-color @@ -9792,13 +9863,13 @@ snapshots: chai: 5.1.2 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.8(vite@5.4.11(@types/node@22.10.6)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0))': + '@vitest/mocker@2.1.8(vite@5.4.11(@types/node@22.10.6)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0))': dependencies: '@vitest/spy': 2.1.8 estree-walker: 3.0.3 magic-string: 0.30.15 optionalDependencies: - vite: 5.4.11(@types/node@22.10.6)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0) + vite: 5.4.11(@types/node@22.10.6)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0) '@vitest/pretty-format@2.1.8': dependencies: @@ -9828,7 +9899,7 @@ snapshots: sirv: 3.0.0 tinyglobby: 0.2.10 tinyrainbow: 1.2.0 - vitest: 2.1.8(@types/node@22.10.6)(@vitest/ui@2.1.8)(jsdom@26.0.0)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0) + vitest: 2.1.8(@types/node@22.10.6)(@vitest/ui@2.1.8)(jsdom@26.0.0)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0) '@vitest/utils@2.1.8': dependencies: @@ -9944,7 +10015,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.3.5 + debug: 4.4.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -10434,8 +10505,8 @@ snapshots: caniuse-api@3.0.0: dependencies: - browserslist: 4.24.2 - caniuse-lite: 1.0.30001687 + browserslist: 4.24.4 + caniuse-lite: 1.0.30001692 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 @@ -10917,10 +10988,6 @@ snapshots: dependencies: ms: 2.1.2 - debug@4.3.5: - dependencies: - ms: 2.1.2 - debug@4.4.0(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -11696,7 +11763,7 @@ snapshots: '@nodelib/fs.walk': 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 - micromatch: 4.0.7 + micromatch: 4.0.8 fast-glob@3.3.3: dependencies: @@ -12059,6 +12126,8 @@ snapshots: globals@14.0.0: {} + globals@15.14.0: {} + globalthis@1.0.4: dependencies: define-properties: 1.2.1 @@ -12080,7 +12149,7 @@ snapshots: '@types/glob': 7.2.0 array-union: 2.1.0 dir-glob: 3.0.1 - fast-glob: 3.3.2 + fast-glob: 3.3.3 glob: 7.2.3 ignore: 5.3.2 merge2: 1.4.1 @@ -12168,7 +12237,7 @@ snapshots: dependencies: '@tootallnate/once': 2.0.0 agent-base: 6.0.2 - debug: 4.3.5 + debug: 4.4.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -12188,7 +12257,7 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.3.5 + debug: 4.4.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -12759,6 +12828,48 @@ snapshots: dependencies: immediate: 3.0.6 + lightningcss-darwin-arm64@1.25.1: + optional: true + + lightningcss-darwin-x64@1.25.1: + optional: true + + lightningcss-freebsd-x64@1.25.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.25.1: + optional: true + + lightningcss-linux-arm64-gnu@1.25.1: + optional: true + + lightningcss-linux-arm64-musl@1.25.1: + optional: true + + lightningcss-linux-x64-gnu@1.25.1: + optional: true + + lightningcss-linux-x64-musl@1.25.1: + optional: true + + lightningcss-win32-x64-msvc@1.25.1: + optional: true + + lightningcss@1.25.1: + dependencies: + detect-libc: 1.0.3 + optionalDependencies: + lightningcss-darwin-arm64: 1.25.1 + lightningcss-darwin-x64: 1.25.1 + lightningcss-freebsd-x64: 1.25.1 + lightningcss-linux-arm-gnueabihf: 1.25.1 + lightningcss-linux-arm64-gnu: 1.25.1 + lightningcss-linux-arm64-musl: 1.25.1 + lightningcss-linux-x64-gnu: 1.25.1 + lightningcss-linux-x64-musl: 1.25.1 + lightningcss-win32-x64-msvc: 1.25.1 + optional: true + lilconfig@2.1.0: {} lilconfig@3.1.3: {} @@ -12866,8 +12977,6 @@ snapshots: lru-cache@10.2.2: {} - lru-cache@11.0.0: {} - lru-cache@11.0.2: {} lru-cache@5.1.1: @@ -12991,11 +13100,6 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - micromatch@4.0.7: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -13448,7 +13552,7 @@ snapshots: path-scurry@2.0.0: dependencies: - lru-cache: 11.0.0 + lru-cache: 11.0.2 minipass: 7.1.2 path-type@4.0.0: {} @@ -13541,7 +13645,7 @@ snapshots: postcss-colormin@5.3.1(postcss@8.5.1): dependencies: - browserslist: 4.24.2 + browserslist: 4.24.4 caniuse-api: 3.0.0 colord: 2.9.3 postcss: 8.5.1 @@ -13549,7 +13653,7 @@ snapshots: postcss-colormin@7.0.2(postcss@8.5.1): dependencies: - browserslist: 4.24.2 + browserslist: 4.24.4 caniuse-api: 3.0.0 colord: 2.9.3 postcss: 8.5.1 @@ -13557,13 +13661,13 @@ snapshots: postcss-convert-values@5.1.3(postcss@8.5.1): dependencies: - browserslist: 4.24.2 + browserslist: 4.24.4 postcss: 8.5.1 postcss-value-parser: 4.2.0 postcss-convert-values@7.0.4(postcss@8.5.1): dependencies: - browserslist: 4.24.2 + browserslist: 4.24.4 postcss: 8.5.1 postcss-value-parser: 4.2.0 @@ -13696,7 +13800,7 @@ snapshots: postcss-merge-rules@5.1.4(postcss@8.5.1): dependencies: - browserslist: 4.24.2 + browserslist: 4.24.4 caniuse-api: 3.0.0 cssnano-utils: 3.1.0(postcss@8.5.1) postcss: 8.5.1 @@ -13704,7 +13808,7 @@ snapshots: postcss-merge-rules@7.0.4(postcss@8.5.1): dependencies: - browserslist: 4.24.2 + browserslist: 4.24.4 caniuse-api: 3.0.0 cssnano-utils: 5.0.0(postcss@8.5.1) postcss: 8.5.1 @@ -13736,14 +13840,14 @@ snapshots: postcss-minify-params@5.1.4(postcss@8.5.1): dependencies: - browserslist: 4.24.2 + browserslist: 4.24.4 cssnano-utils: 3.1.0(postcss@8.5.1) postcss: 8.5.1 postcss-value-parser: 4.2.0 postcss-minify-params@7.0.2(postcss@8.5.1): dependencies: - browserslist: 4.24.2 + browserslist: 4.24.4 cssnano-utils: 5.0.0(postcss@8.5.1) postcss: 8.5.1 postcss-value-parser: 4.2.0 @@ -13859,13 +13963,13 @@ snapshots: postcss-normalize-unicode@5.1.1(postcss@8.5.1): dependencies: - browserslist: 4.24.2 + browserslist: 4.24.4 postcss: 8.5.1 postcss-value-parser: 4.2.0 postcss-normalize-unicode@7.0.2(postcss@8.5.1): dependencies: - browserslist: 4.24.2 + browserslist: 4.24.4 postcss: 8.5.1 postcss-value-parser: 4.2.0 @@ -13994,13 +14098,13 @@ snapshots: postcss-reduce-initial@5.1.2(postcss@8.5.1): dependencies: - browserslist: 4.24.2 + browserslist: 4.24.4 caniuse-api: 3.0.0 postcss: 8.5.1 postcss-reduce-initial@7.0.2(postcss@8.5.1): dependencies: - browserslist: 4.24.2 + browserslist: 4.24.4 caniuse-api: 3.0.0 postcss: 8.5.1 @@ -14146,7 +14250,7 @@ snapshots: proxy-agent@6.4.0: dependencies: agent-base: 7.1.3 - debug: 4.3.4 + debug: 4.4.0(supports-color@8.1.1) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -14599,8 +14703,6 @@ snapshots: dependencies: lru-cache: 6.0.0 - semver@7.6.3: {} - serialize-error@7.0.1: dependencies: type-fest: 0.13.1 @@ -14739,7 +14841,7 @@ snapshots: socks-proxy-agent@7.0.0: dependencies: agent-base: 6.0.2 - debug: 4.3.5 + debug: 4.4.0(supports-color@8.1.1) socks: 2.8.1 transitivePeerDependencies: - supports-color @@ -14968,13 +15070,13 @@ snapshots: stylehacks@5.1.1(postcss@8.5.1): dependencies: - browserslist: 4.24.2 + browserslist: 4.24.4 postcss: 8.5.1 postcss-selector-parser: 6.1.2 stylehacks@7.0.4(postcss@8.5.1): dependencies: - browserslist: 4.24.2 + browserslist: 4.24.4 postcss: 8.5.1 postcss-selector-parser: 6.1.2 @@ -15276,7 +15378,7 @@ snapshots: tuf-js@1.1.7: dependencies: '@tufjs/models': 1.0.4 - debug: 4.3.5 + debug: 4.4.0(supports-color@8.1.1) make-fetch-happen: 11.1.1 transitivePeerDependencies: - supports-color @@ -15497,13 +15599,13 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 - vite-node@2.1.8(@types/node@22.10.6)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0): + vite-node@2.1.8(@types/node@22.10.6)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0): dependencies: cac: 6.7.14 debug: 4.4.0(supports-color@8.1.1) es-module-lexer: 1.5.4 pathe: 1.1.2 - vite: 5.4.11(@types/node@22.10.6)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0) + vite: 5.4.11(@types/node@22.10.6)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0) transitivePeerDependencies: - '@types/node' - less @@ -15515,18 +15617,18 @@ snapshots: - supports-color - terser - vite-tsconfig-paths@5.1.4(typescript@5.7.3)(vite@6.0.7(@types/node@22.10.6)(jiti@2.4.1)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)(yaml@2.6.1)): + vite-tsconfig-paths@5.1.4(typescript@5.7.3)(vite@6.0.7(@types/node@22.10.6)(jiti@2.4.1)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)(yaml@2.6.1)): dependencies: debug: 4.4.0(supports-color@8.1.1) globrex: 0.1.2 tsconfck: 3.1.4(typescript@5.7.3) optionalDependencies: - vite: 6.0.7(@types/node@22.10.6)(jiti@2.4.1)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)(yaml@2.6.1) + vite: 6.0.7(@types/node@22.10.6)(jiti@2.4.1)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)(yaml@2.6.1) transitivePeerDependencies: - supports-color - typescript - vite@5.4.11(@types/node@22.10.6)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0): + vite@5.4.11(@types/node@22.10.6)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0): dependencies: esbuild: 0.21.5 postcss: 8.5.1 @@ -15535,11 +15637,12 @@ snapshots: '@types/node': 22.10.6 fsevents: 2.3.3 less: 4.2.0 + lightningcss: 1.25.1 sass: 1.83.4 stylus: 0.63.0 terser: 5.37.0 - vite@6.0.7(@types/node@22.10.6)(jiti@2.4.1)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)(yaml@2.6.1): + vite@6.0.7(@types/node@22.10.6)(jiti@2.4.1)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)(yaml@2.6.1): dependencies: esbuild: 0.24.2 postcss: 8.5.1 @@ -15549,15 +15652,16 @@ snapshots: fsevents: 2.3.3 jiti: 2.4.1 less: 4.2.0 + lightningcss: 1.25.1 sass: 1.83.4 stylus: 0.63.0 terser: 5.37.0 yaml: 2.6.1 - vitest@2.1.8(@types/node@22.10.6)(@vitest/ui@2.1.8)(jsdom@26.0.0)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0): + vitest@2.1.8(@types/node@22.10.6)(@vitest/ui@2.1.8)(jsdom@26.0.0)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0): dependencies: '@vitest/expect': 2.1.8 - '@vitest/mocker': 2.1.8(vite@5.4.11(@types/node@22.10.6)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)) + '@vitest/mocker': 2.1.8(vite@5.4.11(@types/node@22.10.6)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0)) '@vitest/pretty-format': 2.1.8 '@vitest/runner': 2.1.8 '@vitest/snapshot': 2.1.8 @@ -15573,8 +15677,8 @@ snapshots: tinyexec: 0.3.1 tinypool: 1.0.2 tinyrainbow: 1.2.0 - vite: 5.4.11(@types/node@22.10.6)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0) - vite-node: 2.1.8(@types/node@22.10.6)(less@4.2.0)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0) + vite: 5.4.11(@types/node@22.10.6)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0) + vite-node: 2.1.8(@types/node@22.10.6)(less@4.2.0)(lightningcss@1.25.1)(sass@1.83.4)(stylus@0.63.0)(terser@5.37.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.10.6 @@ -15622,7 +15726,7 @@ snapshots: '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.14.0 - browserslist: 4.24.2 + browserslist: 4.24.4 chrome-trace-event: 1.0.4 enhanced-resolve: 5.17.1 es-module-lexer: 1.5.4 diff --git a/src/components/effects/__tests__/useClickOutside.test.tsx b/src/components/effects/__tests__/useClickOutside.test.tsx index 4c55ec08..c2ae919b 100644 --- a/src/components/effects/__tests__/useClickOutside.test.tsx +++ b/src/components/effects/__tests__/useClickOutside.test.tsx @@ -1,42 +1,35 @@ -// useCloseClickOutside.test.ts - import { renderHook } from '@testing-library/react'; import { vi } from 'vitest'; import useCloseClickOutside from '../useCloseClickOutside'; describe('useCloseClickOutside', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('calls the callback when clicking outside', () => { const callback = vi.fn(); - const { result } = renderHook(() => - useCloseClickOutside( - { current: document.createElement('div') }, - callback, - ), - ); + const div = document.createElement('div'); + renderHook(() => useCloseClickOutside({ current: div }, callback)); document.body.click(); - expect(callback).toHaveBeenCalled(); + expect(callback).toHaveBeenCalledTimes(1); }); it('does not call the callback when clicking inside', () => { const callback = vi.fn(); const div = document.createElement('div'); - const { result } = renderHook(() => - useCloseClickOutside({ current: div }, callback), - ); + renderHook(() => useCloseClickOutside({ current: div }, callback)); div.click(); expect(callback).not.toHaveBeenCalled(); }); - // Additional test cases - it('handles missing ref', () => { const callback = vi.fn(); - renderHook(() => useCloseClickOutside({ current: null }, callback)); document.body.click(); @@ -44,19 +37,53 @@ describe('useCloseClickOutside', () => { expect(callback).not.toHaveBeenCalled(); }); - it('cleans up event listener on unmount', () => { + it('calls callback when escape key is pressed', () => { + const callback = vi.fn(); + const div = document.createElement('div'); + renderHook(() => useCloseClickOutside({ current: div }, callback)); + + div.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('does not call callback for non-escape keys', () => { + const callback = vi.fn(); + const div = document.createElement('div'); + renderHook(() => useCloseClickOutside({ current: div }, callback)); + + div.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('properly cleans up all event listeners on unmount', () => { const callback = vi.fn(); + const div = document.createElement('div'); const { unmount } = renderHook(() => - useCloseClickOutside( - { current: document.createElement('div') }, - callback, - ), + useCloseClickOutside({ current: div }, callback), ); unmount(); - document.body.click(); + div.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); expect(callback).not.toHaveBeenCalled(); }); + + it('handles callback updates correctly', () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + const div = document.createElement('div'); + const { rerender } = renderHook( + ({ cb }) => useCloseClickOutside({ current: div }, cb), + { initialProps: { cb: callback1 } } + ); + + rerender({ cb: callback2 }); + document.body.click(); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/effects/__tests__/useMatchMedia.test.tsx b/src/components/effects/__tests__/useMatchMedia.test.tsx index c3e5bf3b..713325d3 100644 --- a/src/components/effects/__tests__/useMatchMedia.test.tsx +++ b/src/components/effects/__tests__/useMatchMedia.test.tsx @@ -1,48 +1,170 @@ -import { render } from '@testing-library/react'; -import { useMatchMedia } from '../useMatchMedia'; // Adjust the path to your hook +import { render, act } from '@testing-library/react'; +import { useMatchMedia } from '../useMatchMedia'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { describe, expect, it, vi } from 'vitest'; - -// Test component to utilize the useMatchMedia hook +// Test component with updated API function TestComponent({ query, - enabled, + options = {}, }: { - enabled: boolean; query: string; + options?: { + enabled?: boolean; + onMatch?: () => void; + debounceDelay?: number; + }; }) { - const matches = useMatchMedia(query, undefined, enabled); - return
{matches ? 'Matches' : 'Does not match'}
; + const matches = useMatchMedia(query, options); + return ( +
{matches ? 'Matches' : 'Does not match'}
+ ); } describe('useMatchMedia', () => { - // Mocking window.matchMedia - beforeAll(() => { - window.matchMedia = vi.fn().mockImplementation((query) => ({ - addEventListener: vi.fn(), + let matchMediaMock: any; + let addEventListenerSpy: any; + let removeEventListenerSpy: any; + + beforeEach(() => { + addEventListenerSpy = vi.fn(); + removeEventListenerSpy = vi.fn(); + + matchMediaMock = vi.fn().mockImplementation((query) => ({ matches: query === '(min-width: 800px)', - removeEventListener: vi.fn(), + addEventListener: addEventListenerSpy, + removeEventListener: removeEventListenerSpy, })); + + window.matchMedia = matchMediaMock; + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + matchMediaMock.mockClear(); + addEventListenerSpy.mockClear(); + removeEventListenerSpy.mockClear(); }); it('returns true if the query matches', () => { - const { getByText } = render( - , + const { getByTestId } = render( + , ); - expect(getByText('Matches')).toBeInTheDocument(); + expect(getByTestId('result')).toHaveTextContent('Matches'); }); it('returns false if the query does not match', () => { - const { getByText } = render( - , + const { getByTestId } = render( + , ); - expect(getByText('Does not match')).toBeInTheDocument(); + expect(getByTestId('result')).toHaveTextContent('Does not match'); }); it('returns false if the hook is disabled', () => { - const { getByText } = render( - , + const { getByTestId } = render( + , + ); + expect(getByTestId('result')).toHaveTextContent('Does not match'); + }); + + it('calls onMatch callback when query matches', () => { + const onMatch = vi.fn(); + render(); + expect(onMatch).toHaveBeenCalledTimes(1); + }); + + it('handles media query changes', () => { + let changeCallback: (event: { matches: boolean }) => void; + + addEventListenerSpy.mockImplementation((event, cb) => { + changeCallback = cb; + }); + + const { getByTestId } = render( + , + ); + + act(() => { + changeCallback({ matches: false }); + }); + + expect(getByTestId('result')).toHaveTextContent('Does not match'); + }); + + it('handles resize events with debounce', async () => { + vi.useFakeTimers(); + + const { getByTestId } = render( + , + ); + + act(() => { + window.dispatchEvent(new Event('resize')); + }); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(getByTestId('result')).toHaveTextContent('Matches'); + + vi.useRealTimers(); + }); + + it('handles errors in matchMedia gracefully', () => { + const originalMatchMedia = window.matchMedia; + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + window.matchMedia = vi.fn().mockImplementation(() => { + throw new Error('matchMedia error'); + }); + + const { getByTestId, unmount } = render( + , ); - expect(getByText('Does not match')).toBeInTheDocument(); + + expect(getByTestId('result')).toHaveTextContent('Does not match'); + expect(consoleErrorSpy).toHaveBeenCalled(); + + unmount(); + consoleErrorSpy.mockRestore(); + window.matchMedia = originalMatchMedia; + }); + + it('respects maxWait option for resize debounce', async () => { + vi.useFakeTimers(); + + const { getByTestId } = render( + , + ); + + act(() => { + window.dispatchEvent(new Event('resize')); + }); + + // maxWait is 1000ms, so this should trigger an update + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(getByTestId('result')).toHaveTextContent('Matches'); + + vi.useRealTimers(); + }); + + it('handles cleanup on query change', () => { + const { rerender } = render(); + + rerender(); + + expect(removeEventListenerSpy).toHaveBeenCalled(); }); }); diff --git a/src/components/effects/useCloseClickOutside.ts b/src/components/effects/useCloseClickOutside.ts index fd91c6cc..b30b2b34 100644 --- a/src/components/effects/useCloseClickOutside.ts +++ b/src/components/effects/useCloseClickOutside.ts @@ -1,45 +1,43 @@ import { RefObject, useCallback, useEffect, useRef } from 'react'; +/** + * Custom hook that handles click outside and escape key events + * @param el - Reference to the DOM element to watch for outside clicks + * @param callback - Function to call when a click outside or escape key is detected + */ export default function useCloseClickOutside( - el: RefObject, + el: RefObject, callback: () => void, ) { - const htmlElement = useRef(null); + const savedCallback = useRef(callback); - const handleClick = useCallback((e: MouseEvent) => { - const element = htmlElement.current; + useEffect(() => { + savedCallback.current = callback; + }, [callback]); - if (element) { - if (!element.contains(e.target as Node)) { - callback(); - } + const handleClick = useCallback((e: MouseEvent) => { + const element = el.current; + if (element && !element.contains(e.target as Node)) { + savedCallback.current(); } - }, []); + }, [el]); const handleEscape = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') { - callback(); + savedCallback.current(); } }, []); useEffect(() => { const element = el.current; + if (!element) return; - if (element) { - htmlElement.current = element; - - element.addEventListener('keyup', handleEscape); - } - }, [el, callback]); - - useEffect(() => { document.addEventListener('click', handleClick); + element.addEventListener('keyup', handleEscape); + return () => { - const element = htmlElement.current; - if (element) { - element.removeEventListener('keyup', handleEscape); - } document.removeEventListener('click', handleClick); + element.removeEventListener('keyup', handleEscape); }; - }, []); + }, [el, handleClick, handleEscape]); } diff --git a/src/components/effects/useMatchMedia.ts b/src/components/effects/useMatchMedia.ts index ee4e4e2f..c62969e9 100644 --- a/src/components/effects/useMatchMedia.ts +++ b/src/components/effects/useMatchMedia.ts @@ -1,67 +1,116 @@ -/** - * The useMatchMedia hook takes a media query string, a callback function, and an enabled boolean. - * It returns a boolean indicating if the media query matches the current viewport and executes the callback if it does. - * - * @param {string} query - The media query string to match against. - * @param {() => void} [cb] - Optional callback function to be executed if the media query matches. - * @param {boolean} [enabled=true] - Whether the hook is enabled or not. - * @returns {boolean} - Whether the media query matches the current viewport. - */ import { useCallback, useEffect, useRef, useState } from 'react'; import { useDebouncedCallback } from 'use-debounce'; +/** + * Configuration options for the useMatchMedia hook + */ +interface MatchMediaOptions { + /** Callback function to execute when media query matches */ + onMatch?: () => void; + /** Whether the hook is enabled */ + enabled?: boolean; + /** Debounce delay in milliseconds */ + debounceDelay?: number; +} + +/** + * Custom hook that tracks if a media query matches and executes a callback on matches + * + * @param query - The media query string to match against + * @param options - Configuration options + * @returns Boolean indicating if the media query currently matches + * + * @example + * ```tsx + * const isMobile = useMatchMedia('(max-width: 768px)', { + * onMatch: () => console.log('Mobile view detected'), + * debounceDelay: 200 + * }); + * ``` + */ export const useMatchMedia = ( query: string, - cb?: () => void, - enabled = true, -) => { + { + onMatch, + enabled = true, + debounceDelay = 100 + }: MatchMediaOptions = {} +): boolean => { const [matches, setMatches] = useState(false); + const mediaQuery = useRef(null); - const media = useRef(window.matchMedia(query)); + /** + * Creates and returns a MediaQueryList object + */ + const createMediaQuery = useCallback((): MediaQueryList | null => { + if (typeof window === 'undefined') return null; + + try { + return window.matchMedia(query); + } catch (error) { + console.error('Error creating media query:', error); + return null; + } + }, [query]); - const listener = useCallback( - () => setMatches(media.current.matches), - [media], + const handleMediaChange = useCallback( + (event: MediaQueryListEvent | MediaQueryList) => { + setMatches(event.matches); + }, + [] ); - const onResize = useDebouncedCallback(() => { - const curMatches = media.current.matches; - - if (curMatches !== matches) { - setMatches(curMatches); + const handleResize = useDebouncedCallback(() => { + if (!mediaQuery.current) return; + + const currentMatches = mediaQuery.current.matches; + if (currentMatches !== matches) { + setMatches(currentMatches); } - }, 100); + }, debounceDelay, { maxWait: 1000 }); // Add maxWait for better performance + // Setup media query listener useEffect(() => { - const currentMedia = media.current; - - if (!enabled || !currentMedia) { + if (!enabled || typeof window === 'undefined') { return; } - const curMacthes = currentMedia.matches; + // Cleanup previous mediaQuery if it exists + if (mediaQuery.current) { + mediaQuery.current.removeEventListener('change', handleMediaChange); + } - // Check initial match and update state if necessary - if (curMacthes !== matches) { - setMatches(curMacthes); + mediaQuery.current = createMediaQuery(); + const currentMedia = mediaQuery.current; + + if (!currentMedia) { + return; } - currentMedia.addEventListener('change', listener); + // Initial check + handleMediaChange(currentMedia); - window.addEventListener('resize', onResize); + // Add event listeners + currentMedia.addEventListener('change', handleMediaChange); + window.addEventListener('resize', handleResize); + // Cleanup return () => { - currentMedia.removeEventListener('change', listener); - - window.removeEventListener('resize', onResize); + if (currentMedia) { + currentMedia.removeEventListener('change', handleMediaChange); + } + window.removeEventListener('resize', handleResize); + handleResize.cancel(); // Cancel any pending debounced calls + mediaQuery.current = null; // Clear the ref }; - }, [query, enabled, media]); + }, [query, enabled, createMediaQuery, handleMediaChange, handleResize]); // Added query dependency + // Execute callback when matches changes useEffect(() => { - if (matches && cb) { - cb(); + if (matches && onMatch) { + onMatch(); } - }, [matches, cb]); + }, [matches, onMatch]); return matches; }; diff --git a/src/components/effects/useSlideshow.ts b/src/components/effects/useSlideshow.ts index 94b62c09..ce05380f 100644 --- a/src/components/effects/useSlideshow.ts +++ b/src/components/effects/useSlideshow.ts @@ -1,5 +1,25 @@ import { RefObject, useCallback, useEffect, useRef, useState } from 'react'; +type SlideshowHookReturn = { + paused: boolean; + remainInterval: number; + setStartWidth: (width: number) => void; + setupTimer: (interval: number) => void; + startWidth: number; + tryPause: () => void; + tryResume: () => void; +}; + +/** + * Custom hook to manage slideshow functionality with pause/resume capabilities + * @param ref - Reference to the HTML element containing the slideshow + * @param active - Whether the current slide is active + * @param slideShowActive - Whether the slideshow functionality is enabled + * @param slideItemDuration - Duration in milliseconds for each slide + * @param id - Unique identifier for the current slide + * @param onElapsed - Callback function triggered when slide duration elapses + * @returns Object containing slideshow control functions and state + */ const useSlideshow = ( ref: RefObject, active: boolean, @@ -7,73 +27,92 @@ const useSlideshow = ( slideItemDuration: number, id: string, onElapsed?: (id: string) => void, -) => { +): SlideshowHookReturn => { const startTime = useRef(null); - const timerRef = useRef(0); - const [startWidth, setStartWidth] = useState(0); - const [paused, setPaused] = useState(false); - const slideShowElapsed = useRef(0); - const [remainInterval, setRemainInterval] = useState(0); + const timerRef = useRef(0); + const [startWidth, setStartWidth] = useState(0); + const [paused, setPaused] = useState(false); + const slideShowElapsed = useRef(0); + const [remainInterval, setRemainInterval] = useState(0); + + /** + * Cleans up the current timer + */ + const cleanupTimer = useCallback(() => { + if (timerRef.current) { + window.clearTimeout(timerRef.current); + timerRef.current = 0; + } + }, []); + /** + * Sets up a new timer for the slideshow + * @param interval - Duration in milliseconds for the timer + */ const setupTimer = useCallback((interval: number) => { - if (!slideItemDuration) { + if (!slideItemDuration || interval <= 0) { return; } + cleanupTimer(); setRemainInterval(interval); - startTime.current = new Date(); - setPaused(false); timerRef.current = window.setTimeout(() => { - // clear the timer and move to the next card - window.clearTimeout(timerRef.current); + cleanupTimer(); setPaused(true); setStartWidth(0); setRemainInterval(slideItemDuration); - id && onElapsed?.(id); + if (id && onElapsed) { + onElapsed(id); + } }, interval); - }, []); + }, [slideItemDuration, id, onElapsed, cleanupTimer]); + /** + * Pauses the current slideshow if conditions are met + */ const tryPause = useCallback(() => { - if (active && slideShowActive) { - window.clearTimeout(timerRef.current); - setPaused(true); + if (!active || !slideShowActive) { + return; + } - if (startTime.current) { - const elapsed: any = +new Date() - +startTime.current; - slideShowElapsed.current = elapsed; - } + cleanupTimer(); + setPaused(true); - if (ref.current) { - setStartWidth(ref.current.clientWidth); - } + if (startTime.current) { + const elapsed = Date.now() - startTime.current.getTime(); + slideShowElapsed.current = elapsed; } - }, [active, slideShowActive]); - // resumes the slide show - const tryResume = useCallback(() => { - if (active && slideShowActive) { - if (!slideItemDuration) { - return; - } + if (ref.current) { + setStartWidth(ref.current.clientWidth); + } + }, [active, slideShowActive, cleanupTimer]); - const remainingInterval = slideItemDuration - slideShowElapsed.current; + /** + * Resumes the slideshow if conditions are met + */ + const tryResume = useCallback(() => { + if (!active || !slideShowActive || !slideItemDuration) { + return; + } + const remainingInterval = slideItemDuration - slideShowElapsed.current; + if (remainingInterval > 0) { setPaused(false); - - if (remainingInterval > 0) { - setupTimer(remainingInterval); - } + setupTimer(remainingInterval); } - }, [active, slideShowActive, slideItemDuration]); + }, [active, slideShowActive, slideItemDuration, setupTimer]); + // Cleanup effect useEffect(() => { - if (timerRef.current && !slideShowActive) { - window.clearTimeout(timerRef.current); + if (!slideShowActive) { + cleanupTimer(); } - }, [slideShowActive]); + return cleanupTimer; + }, [slideShowActive, cleanupTimer]); return { paused, diff --git a/src/components/elements/list/list-item.tsx b/src/components/elements/list/list-item.tsx index 19ada9c9..43f814bb 100644 --- a/src/components/elements/list/list-item.tsx +++ b/src/components/elements/list/list-item.tsx @@ -10,6 +10,20 @@ import { TitleStyle, } from './list.styles'; +/** + * ListItem component displays a selectable/clickable item with title and description + * @component + * @param {Object} props - Component props + * @param {string} props.title - The title text of the list item + * @param {string} props.id - Unique identifier for the list item + * @param {string} props.description - Optional description text below the title + * @param {Theme} props.theme - Theme object for styling + * @param {(id: string) => void} props.onClick - Click handler function + * @param {boolean} props.active - Whether the item is in active state + * @param {boolean} [props.selected=false] - Whether the item is selected (for checkbox mode) + * @param {boolean} [props.selectable=false] - Whether the item shows a checkbox + * @returns {JSX.Element} Rendered ListItem component + */ const ListItem: FunctionComponent = memo( ({ title, @@ -21,8 +35,17 @@ const ListItem: FunctionComponent = memo( selected = false, selectable = false, }: ListItemModel) => { - const handleOnClick = useCallback((id: string) => onClick?.(id), []); + /** + * Memoized click handler + * @param {string} id - Item identifier + */ + const handleOnClick = useCallback((id: string) => onClick?.(id), [onClick]); + /** + * Keyboard event handler for accessibility + * @param {KeyboardEvent} ev - Keyboard event + * @param {string} id - Item identifier + */ const handleKeyPress = useCallback((ev: KeyboardEvent, id: string) => { if (ev.key === 'Enter') { handleOnClick(id); @@ -31,6 +54,7 @@ const ListItem: FunctionComponent = memo( return ( handleOnClick(id)} @@ -41,7 +65,12 @@ const ListItem: FunctionComponent = memo( > {selectable ? ( - + {selected && } diff --git a/src/components/elements/list/list.styles.ts b/src/components/elements/list/list.styles.ts index d9906bea..55f4526f 100644 --- a/src/components/elements/list/list.styles.ts +++ b/src/components/elements/list/list.styles.ts @@ -1,57 +1,65 @@ import { Theme } from '@models/Theme'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; -// Style constants -const BACKGROUND_COLOR = '#f5f5f5'; -const BORDER_COLOR = 'rgba(0, 0, 0, 0.1)'; +// Theme-specific constants +const themeStyles = { + border: (theme: Theme) => `1px solid ${theme.primary}`, + transparent: '1px solid transparent' +}; -// Common styles -const commonStyles = ` - align-items: center; - background: ${BACKGROUND_COLOR}; - border-radius: 4px; - box-shadow: 0px 1px 1px ${BORDER_COLOR}; +// Reusable flex container mixin +const flexContainer = css` display: flex; + align-items: center; +`; + +// Base styles for list items and containers +const baseStyles = css` + ${flexContainer} margin: 0; - margin-bottom: 0.5rem; - // padding: 0.25rem 0.5rem; width: 100%; +`; + +// Common styles with improved typing +const commonStyles = css` + ${baseStyles} + background: #f5f5f5; + border-radius: 4px; + box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.1); + margin-bottom: 0.5rem; &:last-child { margin-bottom: 0; } `; -// List styles +// Main list container export const ListStyle = styled.ul` - display: flex; + ${baseStyles} flex-direction: column; justify-content: center; list-style: none; - margin: 0; padding: 0; - width: 100%; - align-items: center; `; -// List item styles +// Interactive list item with theme support export const ListItemStyle = styled.li<{ $active?: boolean; $selectable?: boolean; $theme: Theme; }>` ${commonStyles} - border: ${(p) => - p.$active ? `1px solid ${p.$theme.primary}` : '1px solid transparent'}; - flex-direction: ${(p) => (p.$selectable ? 'row' : 'column')}; + border: ${(p) => p.$active ? themeStyles.border(p.$theme) : themeStyles.transparent}; + flex-direction: ${(p) => p.$selectable ? 'row' : 'column'}; background: ${(p) => p.$theme.toolbarBtnBgColor}; + padding: 0.25rem; + width: calc(100% - 0.5rem); + user-select: none; + &:hover { - border: 1px solid ${(p) => p.$theme.primary}; + border: ${(p) => themeStyles.border(p.$theme)}; cursor: pointer; } - user-select: none; - padding: 0.25rem; - width: calc(100% - 0.5rem); `; // Title styles @@ -62,6 +70,7 @@ export const TitleStyle = styled.h1<{ theme: Theme }>` margin: 0.2rem 0; text-align: left; white-space: nowrap; + align-self: flex-start; `; // Title description styles @@ -75,28 +84,23 @@ export const TitleDescriptionStyle = styled.p<{ theme: Theme }>` color: ${(p) => p.theme.cardSubtitleColor}; `; -// Checkbox wrapper styles +// Checkbox components with improved structure export const CheckboxWrapper = styled.span` + ${flexContainer} width: 2rem; - display: flex; - align-items: center; justify-content: center; `; -// Checkbox styles export const CheckboxStyle = styled.span<{ selected?: boolean; theme: Theme }>` - align-items: center; - background-color: white; - ${(p) => !p.selected && `box-shadow: inset 0 0 0 1px ${BORDER_COLOR};`} - background: ${(p) => (p.selected ? p.theme.primary : p.theme.toolbarBgColor)}; - color: #fff; - border-radius: 50%; - display: flex; - height: 1.25rem; + ${flexContainer} justify-content: center; - margin-right: 0.25rem; - margin-left: 0.1rem; width: 1.25rem; + height: 1.25rem; + margin: 0 0.25rem 0 0.1rem; + border-radius: 50%; + background: ${(p) => p.selected ? p.theme.primary : p.theme.toolbarBgColor}; + ${(p) => !p.selected && `box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1)`}; + color: #fff; svg { width: 80%; @@ -104,9 +108,9 @@ export const CheckboxStyle = styled.span<{ selected?: boolean; theme: Theme }>` } `; -// Style and description wrapper styles +// Content wrapper with conditional width export const StyleAndDescription = styled.div<{ $selectable?: boolean }>` + ${flexContainer} flex-direction: column; - display: flex; - width: ${(p) => (p.$selectable ? 'calc(100% - 2rem)' : '100%')}; + width: ${(p) => p.$selectable ? 'calc(100% - 2rem)' : '100%'}; `; diff --git a/src/components/elements/list/list.tsx b/src/components/elements/list/list.tsx index 7017b953..08387f93 100644 --- a/src/components/elements/list/list.tsx +++ b/src/components/elements/list/list.tsx @@ -1,16 +1,28 @@ -// Import necessary dependencies and utilities import { getUniqueID } from '@utils/index'; -import { - FunctionComponent, - startTransition, - useCallback, - useState, -} from 'react'; +import { FunctionComponent, startTransition, useCallback, useMemo } from 'react'; import { ListItem } from './list-item'; import { ListModel } from './list.model'; import { ListStyle } from './list.styles'; -// Define the List component +/** + * Extends the base list item with a unique identifier + * @typedef {Object} EnhancedListItem + * @extends {ListModel['items'][0]} + * @property {string} id - Unique identifier for the list item + */ +type EnhancedListItem = ListModel['items'][0] & { id: string }; + +/** + * List component that renders selectable items with optional multi-select capability + * @component + * @param {Object} props - Component props + * @param {Array} props.items - Array of items to render in the list + * @param {string} props.theme - Theme configuration for styling + * @param {(id: string) => void} [props.onClick] - Callback function when an item is clicked + * @param {number} [props.activeItemIndex] - Index of the currently active item + * @param {boolean} [props.multiSelectable=false] - Enable multi-select functionality + * @returns {JSX.Element} Rendered list component + */ const List: FunctionComponent = ({ items, theme, @@ -18,71 +30,57 @@ const List: FunctionComponent = ({ activeItemIndex, multiSelectable = false, }) => { - // Initialize state for list items - const [listItems, setListItems] = useState(() => - items.map((item) => ({ - id: getUniqueID(), - ...item, - })), + /** + * Memoized list items with generated unique IDs + */ + const listItems = useMemo( + () => items.map((item) => ({ id: getUniqueID(), ...item })), + [items] ); - // Callback function for handling checkbox selection - const onChecked = useCallback((id: string) => { - const updatedItems = listItems.map((item) => { - if (item.id === id) { - return { - ...item, - selected: true, - }; - } else { - return { - ...item, - selected: false, - }; - } - }); - - setListItems(updatedItems); - }, []); - - // Callback function for handling item click - const handleClick = useCallback((id: string) => { - onChecked(id); - - if (multiSelectable) { - const item = listItems.find((item) => item.id === id); - - if (item.onSelect) { + /** + * Handles item selection and triggers appropriate callbacks + * @param {string} id - Item identifier + * @param {EnhancedListItem} item - Selected list item + */ + const handleItemSelection = useCallback( + (id: string, item: EnhancedListItem) => { + if (multiSelectable && item.onSelect) { startTransition(() => { item.onSelect(); }); + } else { + onClick?.(id); } - } else { - onClick?.(id); - } - }, []); + }, + [multiSelectable, onClick] + ); + + /** + * Renders individual list items with proper props + * @param {EnhancedListItem} item - Item to render + * @param {number} index - Item index in the list + * @returns {JSX.Element} Rendered list item + */ + const renderListItem = useCallback( + (item: EnhancedListItem, index: number) => ( + handleItemSelection(item.id, item)} + selectable={multiSelectable} + active={activeItemIndex === index} + /> + ), + [theme, handleItemSelection, multiSelectable, activeItemIndex] + ); - // Render the List component return ( - {listItems?.map(({ title, id, description, selected }, index) => { - return ( - - ); - })} + {listItems.map(renderListItem)} ); }; -// Export the List component export { List }; diff --git a/src/components/elements/popover/__tests__/popover.test.tsx b/src/components/elements/popover/__tests__/popover.test.tsx index d6fc9e87..117da1cc 100644 --- a/src/components/elements/popover/__tests__/popover.test.tsx +++ b/src/components/elements/popover/__tests__/popover.test.tsx @@ -4,7 +4,7 @@ import { vi } from 'vitest'; import userEvent from '@testing-library/user-event'; import { getDefaultThemeOrDark } from '@utils/index'; import { customRender, providerProps } from 'src/components/common/test'; -import { PopOver } from '../index'; +import PopOver from '../index'; describe('PopOver', () => { const mockClosePopover = vi.fn(); diff --git a/src/components/elements/popover/index.tsx b/src/components/elements/popover/index.tsx index 8df14244..af08d45f 100644 --- a/src/components/elements/popover/index.tsx +++ b/src/components/elements/popover/index.tsx @@ -2,8 +2,9 @@ import React, { FunctionComponent, useCallback, useEffect, + useReducer, useRef, - useState, + memo, } from 'react'; import useCloseClickOutside from 'src/components/effects/useCloseClickOutside'; import { ChevronDown, CloseIcon } from 'src/components/icons'; @@ -19,6 +20,36 @@ import { SelecterLabel, } from './popover.styles'; +// Memoized Content component +const MemoizedContent = memo(({ children }: { children: React.ReactNode }) => ( + {children} +)); + +// Reducer for state management +type State = { open: boolean; isVisible: boolean }; +type Action = + | { type: 'TOGGLE' } + | { type: 'CLOSE' } + | { type: 'SET_VISIBLE'; payload: boolean }; + +const popoverReducer = (state: State, action: Action): State => { + switch (action.type) { + case 'TOGGLE': + return { ...state, open: !state.open }; + case 'CLOSE': + return { ...state, open: false }; + case 'SET_VISIBLE': + return { ...state, isVisible: action.payload }; + default: + return state; + } +}; + +/** + * A customizable popover component that displays content in a floating panel + * @param {PopOverModel} props - Component props + * @returns {JSX.Element} PopOver component + */ const PopOver: FunctionComponent = ({ children, position, @@ -29,70 +60,79 @@ const PopOver: FunctionComponent = ({ icon, $isMobile = false, }) => { - const [open, setOpen] = useState(false); - const [isVisible, setIsVisible] = useState(false); const ref = useRef(null); + const [state, dispatch] = useReducer(popoverReducer, { + open: false, + isVisible: false, + }); - const toggleOpen = useCallback(() => setOpen(!open), []); + const toggleOpen = useCallback(() => { + dispatch({ type: 'TOGGLE' }); + }, []); - const closePopover = useCallback(() => setOpen(false), []); + const closePopover = useCallback(() => { + dispatch({ type: 'CLOSE' }); + }, []); const handleKeyPress = useCallback((ev: React.KeyboardEvent) => { if (ev.key === 'Enter') { - toggleOpen(); + dispatch({ type: 'TOGGLE' }); } }, []); useCloseClickOutside(ref, closePopover); + // Use CSS transition instead of setTimeout useEffect(() => { - if (open) { - setTimeout(() => { - setIsVisible(true); - }, 10); + if (state.open) { + requestAnimationFrame(() => { + dispatch({ type: 'SET_VISIBLE', payload: true }); + }); } else { - setIsVisible(false); + dispatch({ type: 'SET_VISIBLE', payload: false }); } - }, [open]); + }, [state.open]); return ( - - - - {icon || } - - {placeholder && !$isMobile ? ( - {placeholder} - ) : null} - - {open ? ( + <> + + + + {icon || } + + {placeholder && !$isMobile ? ( + {placeholder} + ) : null} + + + {state.open ? (
- {children} + {children}
) : null} -
+ ); }; -export { PopOver }; +export default memo(PopOver); diff --git a/src/components/elements/popover/popover.styles.ts b/src/components/elements/popover/popover.styles.ts index 406d49b3..1db68ba0 100644 --- a/src/components/elements/popover/popover.styles.ts +++ b/src/components/elements/popover/popover.styles.ts @@ -1,8 +1,24 @@ import { Theme } from '@models/Theme'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; -export const PopoverWrapper = styled.div``; +// Utility mixin for centering content using flexbox +const flexCenter = css` + display: flex; + align-items: center; + justify-content: center; +`; + +// Dynamic box shadow generator based on dark mode and open state +const boxShadow = (isDarkMode: boolean, open: boolean) => + !open + ? `0px 1px 1px rgba(0, 0, 0, ${isDarkMode ? '0.85' : '0.2'})` + : 'inset 0 0 1px 1px rgba(0, 0, 0, 0.2)'; +// Base wrapper for the popover component +export const PopoverWrapper = styled.div` +`; + +// Main popover container with positioning and visibility controls export const PopoverHolder = styled.div<{ $isMobile?: boolean; $position?: 'top' | 'bottom'; @@ -10,54 +26,50 @@ export const PopoverHolder = styled.div<{ $visible?: boolean; $width: number; }>` - align-items: flex-start; + ${flexCenter}; + flex-direction: column; background: ${({ $theme }) => $theme.toolbarBgColor}; - background:; border-radius: 6px; box-shadow: 0px 5px 16px rgba(0, 0, 0, 0.5); - display: flex; - flex-direction: column; - justify-content: space-between; max-height: 500px; overflow-y: auto; padding: 0.5rem; position: absolute; ${(p) => (p.$position === 'bottom' ? `bottom: 3.5rem` : `top: 4rem`)}; ${(p) => (p.$isMobile ? 'left: 4px;' : '')}; - width: ${({ $isMobile, $width }) => ($isMobile ? '90%' : `${$width}px`)}; - z-index: 100; + width: ${({ $isMobile, $width = 300 }) => + $isMobile ? '90%' : `${$width}px`}; opacity: ${({ $visible }) => ($visible ? 1 : 0)}; - transition: opacity 0.1s ease-in-out; + transition: + opacity 0.2s ease-in-out, + transform 0.2s ease-in-out; + transform: ${(p) => (p.$visible ? 'translateY(0)' : 'translateY(-10px)')}; + z-index: 99999; `; +// Clickable selector button that triggers the popover export const Selecter = styled.div<{ $isDarkMode: boolean; $isMobile?: boolean; $open?: boolean; $theme: Theme; }>` - align-items: center; + ${flexCenter}; background: ${({ $theme }) => $theme.toolbarBtnBgColor}; color: ${({ $theme }) => $theme.toolbarTextColor}; border-radius: 25px; - box-shadow: ${({ $open, $isDarkMode }) => - !$open - ? `0px 1px 1px rgba(0, 0, 0, ${$isDarkMode ? '0.85' : '0.2'})` - : 'inset 0 0 1px 1px rgba(0, 0, 0, 0.2)'}; + box-shadow: ${({ $open, $isDarkMode }) => boxShadow($isDarkMode, $open)}; cursor: pointer; - display: flex; - font-weight: normal; justify-content: space-between; padding: ${(p) => (p.$isMobile ? '0.4rem' : `0.4rem 0.5rem`)}; user-select: none; `; -export const SelecterIcon = styled.span<{ open: boolean; theme: Theme }>` - align-items: center; - color: ${({ theme }) => theme.primary}; - display: flex; +// Icon component within the selector with rotation animation +export const SelecterIcon = styled.span<{ $open: boolean; $theme: Theme }>` + ${flexCenter}; + color: ${({ $theme }) => $theme.primary}; height: 1.25rem; - justify-content: center; width: 1.25rem; transition: transform 0.2s ease-in-out; margin-right: 0.1rem; @@ -68,31 +80,33 @@ export const SelecterIcon = styled.span<{ open: boolean; theme: Theme }>` } `; +// Text label for the selector button export const SelecterLabel = styled.span` font-size: 0.9rem; text-align: left; white-space: nowrap; `; +// Top section of the popover containing controls export const Header = styled.div` height: 30px; width: 100%; `; +// Scrollable content area of the popover export const Content = styled.div` height: calc(100% - 30px); overflow-y: auto; - width: calc(100% - 0rem); + width: 100%; `; +// Close button with icon for dismissing the popover export const CloseButton = styled.button<{ theme: Theme }>` - align-items: center; + ${flexCenter}; background: transparent; border: none; color: ${({ theme }) => theme.primary}; cursor: pointer; - display: flex; - justify-content: center; margin-bottom: 0.5rem; margin-left: auto; `; diff --git a/src/components/timeline/timeline-popover-elements.tsx b/src/components/timeline/timeline-popover-elements.tsx index 385fb0d7..30247e4f 100644 --- a/src/components/timeline/timeline-popover-elements.tsx +++ b/src/components/timeline/timeline-popover-elements.tsx @@ -1,7 +1,7 @@ import { FunctionComponent, useContext, useMemo } from 'react'; import { GlobalContext } from '../GlobalContext'; import { List } from '../elements/list/list'; -import { PopOver } from '../elements/popover'; +import PopOver from '../elements/popover'; import { ArrowDownIcon, LayoutIcon, ParaIcon } from '../icons'; import { ChangeDensityProp, diff --git a/src/components/timeline/timeline.style.ts b/src/components/timeline/timeline.style.ts index d9f2699a..30ed8cf1 100644 --- a/src/components/timeline/timeline.style.ts +++ b/src/components/timeline/timeline.style.ts @@ -10,6 +10,7 @@ export const Wrapper = styled.div<{ flex-direction: column; /* cannot remove this */ height: 100%; + z-index: 0; &:focus { outline: 0; @@ -132,6 +133,7 @@ export const ToolbarWrapper = styled.div<{ position: 'top' | 'bottom' }>` padding: 0; margin: ${(p) => (p.position === 'top' ? '0 0 20px 0' : '20px 0 0 0')}; order: ${(p) => (p.position === 'top' ? 0 : 1)}; + z-index: 1; `; export const ExtraControls = styled.ul<{ diff --git a/src/components/toolbar/__tests__/toolbar.test.tsx b/src/components/toolbar/__tests__/toolbar.test.tsx index a9fbd22e..2f78feb2 100644 --- a/src/components/toolbar/__tests__/toolbar.test.tsx +++ b/src/components/toolbar/__tests__/toolbar.test.tsx @@ -3,18 +3,19 @@ import { render } from '@testing-library/react'; import { getDefaultThemeOrDark } from '@utils/index'; import { ThemeProvider } from 'styled-components'; -import { Toolbar, ToolbarItem } from '../index'; +import { Toolbar } from '../index'; +import { ToolbarItem } from '@models/ToolbarItem'; const items: ToolbarItem[] = [ - { name: 'Item 1', onSelect: () => {} }, - { name: 'Item 2', onSelect: () => {} }, + { name: 'Item 1', onSelect: () => {}, id: '1' }, + { name: 'Item 2', onSelect: () => {}, id: '2' }, ]; const theme = getDefaultThemeOrDark(); describe('Toolbar', () => { it('renders toolbar items', () => { - const { getByText } = render( + const { getByText, baseElement } = render( {items.map((item, index) => ( @@ -24,6 +25,8 @@ describe('Toolbar', () => { , ); + console.log(baseElement.innerHTML); + expect(getByText(/Item 1/i)).toBeInTheDocument(); expect(getByText(/Item 2/i)).toBeInTheDocument(); }); @@ -32,6 +35,7 @@ describe('Toolbar', () => { const itemWithIcon = { icon: Icon, name: 'Icon Item', + id: '3', onSelect: () => {}, }; diff --git a/src/components/toolbar/index.tsx b/src/components/toolbar/index.tsx index 71b7b193..c8caaee4 100644 --- a/src/components/toolbar/index.tsx +++ b/src/components/toolbar/index.tsx @@ -1,4 +1,4 @@ -import { FunctionComponent } from 'react'; +import { FunctionComponent, memo } from 'react'; import { jsx as _jsx } from 'react/jsx-runtime'; import { ContentWrapper, @@ -8,23 +8,57 @@ import { } from './toolbar.styles'; import { ToolbarProps } from '@models/ToolbarProps'; -const Toolbar: FunctionComponent = ({ - items, - children = [], - theme, -}) => { - return ( - - {items?.map(({ label, id, icon }, index) => { - return ( - - {icon ? {icon} : null} - {children[index]} - - ); - })} - - ); -}; +/** + * @description A reusable toolbar component that renders a list of items with icons and content + * @component + * @param {Object} props - Component properties + * @param {Array} props.items - Array of toolbar items to render + * @param {ReactNode[]} props.children - Child elements to render within each toolbar item + * @param {Theme} props.theme - Theme configuration for styling + * + * @example + * ```tsx + * }]} + * theme={theme} + * > + * + * + * ``` + */ +const Toolbar: FunctionComponent = memo( + ({ items = [], children = [], theme }) => { + if (!items.length) { + return null; + } + + return ( + + {items.map(({ label, id, icon }, index) => { + if (!id) { + console.warn('Toolbar item is missing required id property'); + return null; + } + + return ( + + {icon && {icon}} + {children[index] && ( + {children[index]} + )} + + ); + })} + + ); + }, +); + +Toolbar.displayName = 'Toolbar'; export { Toolbar }; diff --git a/src/components/toolbar/toolbar.styles.ts b/src/components/toolbar/toolbar.styles.ts index 4b8763ea..c835db71 100644 --- a/src/components/toolbar/toolbar.styles.ts +++ b/src/components/toolbar/toolbar.styles.ts @@ -1,33 +1,43 @@ import { Theme } from '@models/Theme'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; +// Base styles for flex containers - using memo to prevent recreation +const flexContainer = css` + display: flex; + align-items: center; +`; + +// Use transform instead of box-shadow for better performance export const ToolbarWrapper = styled.ul<{ theme: Theme }>` + ${flexContainer}; list-style: none; margin: 0; - display: flex; - align-items: center; - background-color: ${(p) => p.theme.toolbarBgColor}; - box-shadow: 0 2px 1px rgba(0, 0, 0, 0.1); + padding: 0.25rem; + background-color: ${({ theme }) => theme.toolbarBgColor}; + transform: translateY(0); + filter: drop-shadow(0 2px 1px rgba(0, 0, 0, 0.1)); width: 100%; height: 100%; border-radius: 6px; flex-wrap: wrap; - padding: 0.25rem; + will-change: transform; `; +// Toolbar list item styles export const ToolbarListItem = styled.li` padding: 0; margin: 0 0.5rem; `; +// Icon wrapper styles export const IconWrapper = styled.span` - display: flex; - align-items: center; + ${flexContainer}; justify-content: center; width: 1rem; height: 1rem; `; +// Content wrapper styles export const ContentWrapper = styled.span` - display: flex; + ${flexContainer}; `; From 54ce7a7d2e671de95dffb2be993cd7c596df4608 Mon Sep 17 00:00:00 2001 From: prabhuignoto Date: Wed, 29 Jan 2025 22:06:11 +0530 Subject: [PATCH 2/5] chore: comment out ESLint configuration in Rollup config --- rollup.config.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rollup.config.mjs b/rollup.config.mjs index fae49746..3dffc5e8 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -146,7 +146,9 @@ export default { comments: false, }, }), - eslint(), + // eslint({ + // overrideConfigFile: "./eslint.config.mjs" + // }), // analyze({ // summaryOnly: true, // }), From b2ff8a1240735fea83d830cfe5d7fe2b212f2992 Mon Sep 17 00:00:00 2001 From: prabhuignoto Date: Thu, 30 Jan 2025 01:26:08 +0530 Subject: [PATCH 3/5] fix build issues --- src/components/GlobalContext.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/GlobalContext.tsx b/src/components/GlobalContext.tsx index e945e94a..a357b264 100644 --- a/src/components/GlobalContext.tsx +++ b/src/components/GlobalContext.tsx @@ -95,20 +95,20 @@ const GlobalContextProvider: FunctionComponent = (props) => { useMatchMedia( `(max-width: ${responsiveBreakPoint - 1}px)`, - () => setIsMobileDetected(true), - enableBreakPoint, + { + onMatch: () => setIsMobileDetected(true), + enabled: enableBreakPoint, + } ); useMatchMedia( `(min-width: ${responsiveBreakPoint}px)`, - () => setIsMobileDetected(false), - enableBreakPoint, + { + onMatch: () => setIsMobileDetected(false), + enabled: enableBreakPoint, + } ); - // useEffect(() => { - // console.log('isMobile', isMobileDetected); - // }, [isMobileDetected]); - const defaultProps = useMemo( () => ({ From a75f677c4ec05eab26bc70d8b2f16c3dfabf498d Mon Sep 17 00:00:00 2001 From: prabhuignoto Date: Thu, 30 Jan 2025 02:13:59 +0530 Subject: [PATCH 4/5] chore: optimize icon rendering in timeline components using useMemo --- rollup.config.mjs | 2 +- .../timeline/timeline-popover-elements.tsx | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/rollup.config.mjs b/rollup.config.mjs index 3dffc5e8..8e71379e 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -14,7 +14,7 @@ import PeerDepsExternalPlugin from 'rollup-plugin-peer-deps-external'; import postcss from 'rollup-plugin-postcss'; import typescript from 'rollup-plugin-typescript2'; import { visualizer } from 'rollup-plugin-visualizer'; -import eslint from '@rollup/plugin-eslint'; +// import eslint from '@rollup/plugin-eslint'; const pkg = JSON.parse(fs.readFileSync('./package.json')); diff --git a/src/components/timeline/timeline-popover-elements.tsx b/src/components/timeline/timeline-popover-elements.tsx index 30247e4f..6b776e19 100644 --- a/src/components/timeline/timeline-popover-elements.tsx +++ b/src/components/timeline/timeline-popover-elements.tsx @@ -19,6 +19,8 @@ const LayoutSwitcher: FunctionComponent = ({ }: LayoutSwitcherProp) => { const { showAllCardsHorizontal, buttonTexts } = useContext(GlobalContext); + const LayoutIconMemo = useMemo(() => , []); + const activeTimelineMode = useMemo( () => mode, [showAllCardsHorizontal, mode], @@ -85,7 +87,7 @@ const LayoutSwitcher: FunctionComponent = ({ position={position} theme={theme} isDarkMode={isDarkMode} - icon={} + icon={LayoutIconMemo} $isMobile={isMobile} > = ({ }: QuickJumpProp) => { const { buttonTexts } = useContext(GlobalContext); + const ArrowDownIconMemo = useMemo(() => , []); + return ( = ({ width={400} isDarkMode={isDarkMode} $isMobile={isMobile} - icon={} + icon={ArrowDownIconMemo} > ({ @@ -148,6 +152,8 @@ const ChangeDensity: FunctionComponent = ({ }) => { const { buttonTexts } = useContext(GlobalContext); + const ParaIconMemo = useMemo(() => , []); + const items = useMemo( () => [ { @@ -176,7 +182,7 @@ const ChangeDensity: FunctionComponent = ({ position={position} $isMobile={isMobile} width={300} - icon={} + icon={ParaIconMemo} > From 94daee5d3f3a90d2bdc63c7c2a9555ef1240df2c Mon Sep 17 00:00:00 2001 From: prabhuignoto Date: Thu, 30 Jan 2025 11:17:00 +0530 Subject: [PATCH 5/5] refactor: improve list item click handling with useCallback --- src/components/elements/list/list.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/elements/list/list.tsx b/src/components/elements/list/list.tsx index 08387f93..477f1f08 100644 --- a/src/components/elements/list/list.tsx +++ b/src/components/elements/list/list.tsx @@ -63,16 +63,21 @@ const List: FunctionComponent = ({ * @returns {JSX.Element} Rendered list item */ const renderListItem = useCallback( - (item: EnhancedListItem, index: number) => ( - { + const handleClick = useCallback( + () => handleItemSelection(item.id, item), + [item, handleItemSelection] + ); + + return handleItemSelection(item.id, item)} + onClick={handleClick} selectable={multiSelectable} active={activeItemIndex === index} /> - ), + }, [theme, handleItemSelection, multiSelectable, activeItemIndex] );