├── .eslintignore ├── src ├── index.js ├── utils.js ├── Dropout.js └── utils.test.js ├── .prettierrc.cjs ├── .huskyrc.cjs ├── .editorconfig ├── .babelrc ├── .codeclimate.yml ├── .gitignore ├── .npmignore ├── CONTRIBUTING.md ├── .eslintrc.cjs ├── LICENSE ├── demo ├── index.html ├── index.js └── index.css ├── .github ├── workflows │ └── PR.yml └── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE.md ├── rollup.config.js ├── playwright.config.ts ├── e2e └── Dropout.spec.ts ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | demo 2 | dist 3 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { Dropout } from './Dropout'; 2 | 3 | export default Dropout; 4 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | }; 5 | -------------------------------------------------------------------------------- /.huskyrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | hooks: { 3 | 'pre-commit': 'yarn lint-staged', 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@babel/plugin-transform-runtime"], 3 | "presets": ["@babel/react"], 4 | "env": { 5 | "test": { 6 | "presets": ["@babel/env", "@babel/react"] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | eslint: 3 | enabled: true 4 | exclude_patterns: 5 | - 'config/' 6 | - 'db/' 7 | - 'dist/' 8 | - 'demo/' 9 | - 'features/' 10 | - '**/node_modules/' 11 | - 'script/' 12 | - '**/spec/' 13 | - '**/test/' 14 | - '**/tests/' 15 | - 'Tests/' 16 | - '**/vendor/' 17 | - '**/*_test.go' 18 | - '**/*.d.ts' 19 | - '**/*.test.js' 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | npm-debug.log* 3 | yarn-debug.log* 4 | yarn-error.log* 5 | *.log 6 | 7 | pids 8 | *.pid 9 | *.seed 10 | *.pid.lock 11 | 12 | dist 13 | coverage 14 | node_modules 15 | typings 16 | .env 17 | .eslintcache 18 | .node_repl_history 19 | .npm 20 | .yarn-integrity 21 | *.tgz 22 | 23 | .DS_Store 24 | .DS_Store? 25 | ._* 26 | .Spotlight-V100 27 | .Trashes 28 | ehthumbs.db 29 | Thumbs.db 30 | /test-results/ 31 | /playwright-report/ 32 | /playwright/.cache/ 33 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | logs 2 | npm-debug.log* 3 | yarn-debug.log* 4 | yarn-error.log* 5 | *.log 6 | 7 | pids 8 | *.pid 9 | *.seed 10 | *.pid.lock 11 | 12 | .github 13 | coverage 14 | demo 15 | node_modules 16 | typings 17 | .babelrc 18 | .editorconfig 19 | .env 20 | .eslintcache 21 | .eslintignore 22 | .eslintrc.js 23 | .gitignore 24 | .node_repl_history 25 | .npm 26 | .yarn-integrity 27 | *.tgz 28 | rollup.config.js 29 | setupTests.js 30 | yarn.lock 31 | 32 | .DS_Store 33 | .DS_Store? 34 | ._* 35 | .Spotlight-V100 36 | .Trashes 37 | ehthumbs.db 38 | Thumbs.db 39 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | When contributing to this repository, please first discuss the change you wish to make via issue. Follow the template provided there to make problem as clear as possible. 3 | 4 | Please note we have a code of conduct, please follow it in all your interactions with the project. 5 | 6 | ## Pull Request Process 7 | 1. Make sure your code follows the code style of this project. 8 | 2. Make changes to the documentation if required. 9 | 3. Cover your changes with tests. 10 | 4. Make sure all new and existing tests passed. 11 | 5. Resolve all threads that came out during code review process. 12 | 6. Pull request will be merged after successful passing the process. 13 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const prettierConfig = require('./.prettierrc.cjs'); 2 | 3 | module.exports = { 4 | extends: ['airbnb', 'airbnb/hooks', 'plugin:prettier/recommended'], 5 | globals: { 6 | beforeEach: true, 7 | describe: true, 8 | document: true, 9 | expect: true, 10 | it: true, 11 | jest: true, 12 | window: true, 13 | }, 14 | parser: '@babel/eslint-parser', 15 | plugins: ['prettier'], 16 | rules: { 17 | 'import/no-extraneous-dependencies': 0, 18 | 'import/prefer-default-export': 0, 19 | 'prettier/prettier': ['error', prettierConfig], 20 | 'react/function-component-definition': [ 21 | 2, 22 | { namedComponents: 'arrow-function' }, 23 | ], 24 | 'react/jsx-filename-extension': 0, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const extendProps = (initialProps) => (externalProps) => ({ 2 | ...externalProps, 3 | ...initialProps, 4 | }); 5 | 6 | export const getItemsIdsByGrades = (items = []) => 7 | Object.entries(items) 8 | .sort( 9 | ( 10 | [indexA, { grade: gradeA = Number(indexA) + items.length }], 11 | [indexB, { grade: gradeB = Number(indexB) + items.length }], 12 | ) => gradeA - gradeB, 13 | ) 14 | .map(([id]) => Number(id)); 15 | 16 | export const hasIndex = (ids) => (_, index) => ids.indexOf(index) !== -1; 17 | 18 | export const getItemsData = (items = [], countToHide = 0) => { 19 | const rangeIndex = items.length - countToHide; 20 | const idsByGrades = getItemsIdsByGrades(items); 21 | const ids = idsByGrades.slice(0, rangeIndex); 22 | const exceedingIds = idsByGrades.slice(rangeIndex); 23 | 24 | return { 25 | countToHide, 26 | exceedingItems: items.filter(hasIndex(exceedingIds)), 27 | items: items.filter(hasIndex(ids)), 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Paweł Nowak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | react-dropout 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/PR.yml: -------------------------------------------------------------------------------- 1 | name: PR checks 2 | on: [pull_request] 3 | jobs: 4 | PR-checks: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - uses: actions/setup-node@v3 9 | with: 10 | node-version: 16 11 | cache: 'yarn' 12 | - run: | 13 | curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 14 | chmod +x ./cc-test-reporter 15 | - run: yarn install --frozen-lockfile 16 | - name: Prepare test reporter 17 | run: ./cc-test-reporter before-build 18 | - name: Run unit tests 19 | run: yarn test:unit:ci 20 | - name: Push code coverage 21 | run: ./cc-test-reporter after-build --exit-code $? 22 | env: 23 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 24 | - name: Install Playwright Browsers 25 | run: yarn playwright install --with-deps 26 | - name: Run Playwright tests 27 | run: yarn test:e2e 28 | - uses: actions/upload-artifact@v3 29 | if: always() 30 | with: 31 | name: eports 32 | path: | 33 | playwright-report/ 34 | test-results/ 35 | retention-days: 10 36 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### Type 4 | 5 | | Type | Value | 6 | |-----------------|-------| 7 | | Bug | | 8 | | Feature request | | 9 | | Question | | 10 | 11 | ### Environment 12 | 13 | | Name | Description | 14 | |---------|-----------------------| 15 | | Version | | 16 | | OS | | 17 | | Browser | | 18 | 19 | ### Actual behavior 20 | 21 | 22 | 23 | ### Expected behavior 24 | 25 | 26 | 27 | ### Steps to reproduce (for bugs) 28 | 29 | 30 | 1. 31 | 2. 32 | 3. 33 | 34 | ### Possible Solution 35 | 36 | 37 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | import eslint from '@rollup/plugin-eslint'; 3 | import livereload from 'rollup-plugin-livereload'; 4 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 5 | import serve from 'rollup-plugin-serve'; 6 | 7 | const { ENVIRONMENT } = process.env; 8 | 9 | const isDevelopment = ENVIRONMENT === 'development'; 10 | const isProduction = ENVIRONMENT === 'production'; 11 | const isTesting = ENVIRONMENT === 'testing'; 12 | const isDevelopmentOrTesting = isDevelopment || isTesting; 13 | const esFormat = { 14 | file: 'dist/Dropout.js', 15 | format: 'es', 16 | }; 17 | const iifeFormat = { 18 | file: 'dist/Dropout.js', 19 | format: 'iife', 20 | globals: { 21 | react: 'React', 22 | 'prop-types': 'PropTypes', 23 | }, 24 | name: 'Dropout', 25 | }; 26 | 27 | export default { 28 | input: 'src/index.js', 29 | output: [ 30 | isProduction && esFormat, 31 | isDevelopmentOrTesting && iifeFormat, 32 | ].filter((x) => x), 33 | // All the used libs needs to be here 34 | external: ['react', 'prop-types'], 35 | plugins: [ 36 | eslint({ 37 | throwOnError: isProduction, 38 | throwOnWarning: isProduction, 39 | }), 40 | isDevelopmentOrTesting && 41 | serve({ 42 | contentBase: ['dist', 'demo', 'node_modules/@babel/standalone'], 43 | historyApiFallback: true, 44 | port: 3000, 45 | }), 46 | isDevelopment && livereload({ watch: ['dist', 'demo'] }), 47 | nodeResolve(), 48 | babel({ babelHelpers: 'runtime', exclude: 'node_modules/**' }), 49 | ].filter((x) => x), 50 | }; 51 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Related Issue 7 | 8 | 9 | 10 | 11 | 12 | ## Motivation and Context 13 | 14 | 15 | ## How Has This Been Tested? 16 | 17 | 18 | 19 | 20 | ## Screenshots (if appropriate): 21 | 22 | ## Types of changes 23 | 24 | - [ ] Bug fix (non-breaking change which fixes an issue) 25 | - [ ] New feature (non-breaking change which adds functionality) 26 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 27 | 28 | ## Checklist: 29 | 30 | 31 | - [ ] My code follows the code style of this project. 32 | - [ ] My change requires a change to the documentation. 33 | - [ ] I have updated the documentation accordingly. 34 | - [ ] I have read the **CONTRIBUTING** document. 35 | - [ ] I have added tests to cover my changes. 36 | - [ ] All new and existing tests passed. 37 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | import { devices } from '@playwright/test'; 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | const config: PlaywrightTestConfig = { 14 | testDir: './e2e', 15 | /* Maximum time one test can run for. */ 16 | timeout: 30 * 1000, 17 | expect: { 18 | /** 19 | * Maximum time expect() should wait for the condition to be met. 20 | * For example in `await expect(locator).toHaveText();` 21 | */ 22 | timeout: 5000 23 | }, 24 | /* Run tests in files in parallel */ 25 | fullyParallel: true, 26 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 27 | forbidOnly: !!process.env.CI, 28 | /* Retry on CI only */ 29 | retries: process.env.CI ? 2 : 0, 30 | /* Opt out of parallel tests on CI. */ 31 | workers: process.env.CI ? 1 : undefined, 32 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 33 | reporter: process.env.CI ? 'dot' : undefined, 34 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 35 | use: { 36 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 37 | actionTimeout: 0, 38 | /* Base URL to use in actions like `await page.goto('/')`. */ 39 | baseURL: 'http://localhost:3000', 40 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 41 | trace: 'on-first-retry', 42 | video: 'on-first-retry' 43 | }, 44 | projects: [ 45 | { 46 | name: 'chromium', 47 | use: { ...devices['Desktop Chrome'] }, 48 | }, 49 | ], 50 | /* Run your local dev server before starting the tests */ 51 | webServer: { 52 | command: 'npm run start', 53 | port: 3000, 54 | }, 55 | }; 56 | 57 | export default config; 58 | -------------------------------------------------------------------------------- /e2e/Dropout.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, devices } from '@playwright/test'; 2 | 3 | test('Dropout adjusts amount of elements visible on the screen based on viewport size', async ({ 4 | page, 5 | }) => { 6 | await page.setViewportSize(devices['iPad (gen 6)'].viewport); 7 | await page.goto('/'); 8 | // TODO: Fix the issue with initial load of more button even though it's not required in 980 width 9 | await page.goto('/'); 10 | 11 | // TODO: Figure out solution for implementational problem with two visible navigations 12 | const navigationLocator = page.getByTestId('navigation').first(); 13 | 14 | await Promise.all( 15 | ['Home', 'About', 'History', 'Career', 'Blog', 'Help', 'FAQ'].map(item => 16 | expect(navigationLocator.getByText(item)).toBeVisible(), 17 | ), 18 | ); 19 | 20 | await navigationLocator.getByText('More').click(); 21 | 22 | await Promise.all( 23 | ['Products', 'Service', 'Articles', 'Contact'].map(item => 24 | expect(navigationLocator.getByText(item)).toBeVisible(), 25 | ), 26 | ); 27 | 28 | await navigationLocator.getByText('Less').click(); 29 | 30 | await page.setViewportSize({ width: 1920, height: 1080 }); 31 | await page.goto('/'); 32 | 33 | await Promise.all( 34 | [ 35 | 'Home', 36 | 'About', 37 | 'History', 38 | 'Career', 39 | 'Blog', 40 | 'Help', 41 | 'FAQ', 42 | 'Products', 43 | 'Service', 44 | 'Articles', 45 | 'Contact', 46 | ].map(item => expect(navigationLocator.getByText(item)).toBeVisible()), 47 | ); 48 | 49 | await expect(navigationLocator.getByText('More')).not.toBeVisible(); 50 | 51 | await page.setViewportSize(devices['Pixel 5'].viewport); 52 | await page.goto('/'); 53 | 54 | await Promise.all( 55 | ['Home', 'About'].map(item => 56 | expect(navigationLocator.getByText(item)).toBeVisible(), 57 | ), 58 | ); 59 | 60 | await navigationLocator.getByText('More').click(); 61 | 62 | await Promise.all( 63 | [ 64 | 'History', 65 | 'Career', 66 | 'Blog', 67 | 'Help', 68 | 'FAQ', 69 | 'Products', 70 | 'Service', 71 | 'Articles', 72 | 'Contact', 73 | ].map(item => expect(navigationLocator.getByText(item)).toBeVisible()), 74 | ); 75 | }); 76 | -------------------------------------------------------------------------------- /src/Dropout.js: -------------------------------------------------------------------------------- 1 | import { arrayOf, func, number, shape } from 'prop-types'; 2 | import React, { 3 | useCallback, 4 | useEffect, 5 | useMemo, 6 | useRef, 7 | useState, 8 | } from 'react'; 9 | 10 | import { extendProps, getItemsData } from './utils'; 11 | 12 | const INITIAL_COUNT_TO_HIDE = 0; 13 | 14 | const Dropout = ({ children, items }) => { 15 | const [countToHide, setCountToHide] = useState(INITIAL_COUNT_TO_HIDE); 16 | const contentRef = useRef(null); 17 | const rootRef = useRef(null); 18 | const shadowContentRef = useRef(null); 19 | const modifyCountToHide = useCallback(() => { 20 | const hasFreeSpace = 21 | shadowContentRef.current.clientWidth !== contentRef.current.clientWidth && 22 | rootRef.current.clientWidth > shadowContentRef.current.clientWidth; 23 | const hasExceedingContent = 24 | rootRef.current.clientWidth <= contentRef.current.clientWidth; 25 | 26 | if (hasFreeSpace) { 27 | setCountToHide((previousCountToHide) => previousCountToHide - 1); 28 | } else if (hasExceedingContent) { 29 | setCountToHide((previousCountToHide) => previousCountToHide + 1); 30 | } 31 | }, []); 32 | const propsGetter = useMemo( 33 | () => ({ 34 | content: extendProps({ ref: contentRef }), 35 | root: extendProps({ ref: rootRef }), 36 | shadowContent: extendProps({ ref: shadowContentRef }), 37 | shadowRoot: extendProps({ 38 | style: { 39 | left: '-100%', 40 | position: 'fixed', 41 | top: '0px', 42 | visibility: 'hidden', 43 | width: '100%', 44 | }, 45 | }), 46 | }), 47 | [], 48 | ); 49 | 50 | useEffect(modifyCountToHide, [countToHide, items, modifyCountToHide]); 51 | useEffect(() => { 52 | const resizeObserver = new window.ResizeObserver(modifyCountToHide); 53 | const ref = rootRef.current; 54 | 55 | resizeObserver.observe(ref); 56 | 57 | return () => resizeObserver.unobserve(ref); 58 | }, [modifyCountToHide]); 59 | 60 | return ( 61 | <> 62 | {children({ 63 | ...getItemsData(items, countToHide), 64 | getContentProps: propsGetter.content, 65 | getRootProps: propsGetter.root, 66 | })} 67 | 68 | {children({ 69 | ...getItemsData(items, Math.max(countToHide - 1, 0)), 70 | getContentProps: propsGetter.shadowContent, 71 | getRootProps: propsGetter.shadowRoot, 72 | })} 73 | 74 | ); 75 | }; 76 | 77 | Dropout.propTypes = { 78 | children: func.isRequired, 79 | items: arrayOf( 80 | shape({ 81 | grade: number, 82 | }), 83 | ), 84 | }; 85 | 86 | Dropout.defaultProps = { 87 | items: [], 88 | }; 89 | 90 | export { Dropout }; 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-dropout", 3 | "version": "1.0.0", 4 | "description": "Easy way of managing navigation in react based apps.", 5 | "main": "dist/Dropout.js", 6 | "repository": "git@github.com:pawelnvk/react-dropout.git", 7 | "author": "Paweł Nowak", 8 | "license": "MIT", 9 | "type": "module", 10 | "files": [ 11 | "dist", 12 | "src" 13 | ], 14 | "keywords": [ 15 | "responsive", 16 | "respo", 17 | "navigation", 18 | "nav", 19 | "smart", 20 | "greedy", 21 | "react" 22 | ], 23 | "homepage": "https://github.com/pawelnvk/react-dropout#readme", 24 | "bugs": { 25 | "url": "https://github.com/pawelnvk/react-dropout/issues" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.19.6", 29 | "@babel/eslint-parser": "^7.19.1", 30 | "@babel/plugin-transform-runtime": "^7.19.6", 31 | "@babel/preset-env": "^7.19.4", 32 | "@babel/preset-react": "^7.18.6", 33 | "@babel/standalone": "^7.20.0", 34 | "@playwright/test": "^1.27.1", 35 | "@rollup/plugin-babel": "^6.0.2", 36 | "@rollup/plugin-commonjs": "^23.0.2", 37 | "@rollup/plugin-eslint": "^9.0.1", 38 | "@rollup/plugin-node-resolve": "^15.0.1", 39 | "eslint": "^8.26.0", 40 | "eslint-config-airbnb": "^19.0.4", 41 | "eslint-config-prettier": "^8.5.0", 42 | "eslint-plugin-import": "^2.26.0", 43 | "eslint-plugin-jsx-a11y": "^6.6.1", 44 | "eslint-plugin-prettier": "^4.2.1", 45 | "eslint-plugin-react": "^7.31.10", 46 | "eslint-plugin-react-hooks": "^4.6.0", 47 | "husky": "^1.1.2", 48 | "jest": "^29.2.2", 49 | "jest-environment-jsdom": "^29.2.2", 50 | "lint-staged": "^13.0.3", 51 | "prettier": "^2.7.1", 52 | "prop-types": "^15.8.1", 53 | "react": "^18.2.0", 54 | "react-dom": "^18.2.0", 55 | "rollup": "^3.2.3", 56 | "rollup-plugin-livereload": "^2.0.5", 57 | "rollup-plugin-serve": "^2.0.1" 58 | }, 59 | "peerDependencies": { 60 | "react": "17 <" 61 | }, 62 | "scripts": { 63 | "build": "ENVIRONMENT=production rollup -c", 64 | "clean": "rimraf dist", 65 | "lint": "yarn eslint src", 66 | "prepublishOnly": "yarn clean && yarn lint && yarn test:unit:ci && yarn test:e2e && yarn build", 67 | "start": "ENVIRONMENT=development rollup -c --watch", 68 | "start:test": "ENVIRONMENT=testing rollup -c --watch", 69 | "test:unit": "jest --env=jsdom --watch", 70 | "test:unit:ci": "jest --env=jsdom --coverage", 71 | "test:e2e": "playwright test" 72 | }, 73 | "lint-staged": { 74 | "*.js": [ 75 | "eslint --fix", 76 | "git add" 77 | ] 78 | }, 79 | "jest": { 80 | "collectCoverageFrom": [ 81 | "src/**/*.js" 82 | ], 83 | "testMatch": [ 84 | "**/?(*.)+(test).[jt]s?(x)" 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/utils.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | extendProps, 3 | getItemsData, 4 | getItemsIdsByGrades, 5 | hasIndex, 6 | } from './utils'; 7 | 8 | describe('extendProps', () => { 9 | it('merges props objects', () => { 10 | const firstProps = { test: 'item' }; 11 | const secondProps = { another: 'value' }; 12 | const expected = { another: 'value', test: 'item' }; 13 | 14 | const result = extendProps(firstProps)(secondProps); 15 | 16 | expect(result).toEqual(expected); 17 | }); 18 | 19 | it('treats first argument as prioritised', () => { 20 | const firstProps = { test: 'item' }; 21 | const secondProps = { test: 'value' }; 22 | const expected = { test: 'item' }; 23 | 24 | const result = extendProps(firstProps)(secondProps); 25 | 26 | expect(result).toEqual(expected); 27 | }); 28 | }); 29 | 30 | describe('getItemsIdsByGrades', () => { 31 | it('returns items ids by grade value', () => { 32 | const items = [ 33 | { grade: 2 }, 34 | { grade: 5 }, 35 | { grade: 1 }, 36 | { grade: 7 }, 37 | { grade: 3 }, 38 | ]; 39 | const expected = [2, 0, 4, 1, 3]; 40 | 41 | const result = getItemsIdsByGrades(items); 42 | 43 | expect(result).toEqual(expected); 44 | }); 45 | 46 | it('does not move items if no grades provided', () => { 47 | const items = [ 48 | { exact: true, page: 'Home', path: '/' }, 49 | { page: 'About', path: '/about' }, 50 | { page: 'History', path: '/history' }, 51 | { page: 'Career', path: '/career' }, 52 | { page: 'Blog', path: '/blog' }, 53 | { page: 'Help', path: '/help' }, 54 | { page: 'FAQ', path: '/faq' }, 55 | { page: 'Products', path: '/products' }, 56 | { page: 'Service', path: '/service' }, 57 | { page: 'Articles', path: '/articles' }, 58 | { page: 'Contact', path: '/contact' }, 59 | ]; 60 | const expected = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; 61 | 62 | const result = getItemsIdsByGrades(items); 63 | 64 | expect(result).toEqual(expected); 65 | }); 66 | }); 67 | 68 | describe('getItemsData', () => { 69 | it('returns hidden count', () => { 70 | const countToHide = 2; 71 | const items = [ 72 | { grade: 2 }, 73 | { grade: 5 }, 74 | { grade: 1 }, 75 | { grade: 7 }, 76 | { grade: 3 }, 77 | ]; 78 | const expected = expect.objectContaining({ countToHide }); 79 | 80 | const result = getItemsData(items, countToHide); 81 | 82 | expect(result).toEqual(expected); 83 | }); 84 | 85 | it('returns items that are in visible range', () => { 86 | const countToHide = 2; 87 | const items = [ 88 | { grade: 2 }, 89 | { grade: 5 }, 90 | { grade: 1 }, 91 | { grade: 7 }, 92 | { grade: 3 }, 93 | ]; 94 | const expected = expect.objectContaining({ 95 | items: [{ grade: 2 }, { grade: 1 }, { grade: 3 }], 96 | }); 97 | 98 | const result = getItemsData(items, countToHide); 99 | 100 | expect(result).toEqual(expected); 101 | }); 102 | 103 | it('returns items that are out of visible range', () => { 104 | const countToHide = 2; 105 | const items = [ 106 | { grade: 2 }, 107 | { grade: 5 }, 108 | { grade: 1 }, 109 | { grade: 7 }, 110 | { grade: 3 }, 111 | ]; 112 | const expected = expect.objectContaining({ 113 | exceedingItems: [{ grade: 5 }, { grade: 7 }], 114 | }); 115 | 116 | const result = getItemsData(items, countToHide); 117 | 118 | expect(result).toEqual(expected); 119 | }); 120 | }); 121 | 122 | describe('hasIndex', () => { 123 | it('returns true if index of item is included in provided list', () => { 124 | const ids = [2]; 125 | const item = [{}, 2]; 126 | 127 | const result = hasIndex(ids)(...item); 128 | 129 | expect(result).toBeTruthy(); 130 | }); 131 | 132 | it('returns false if index of item is not included in provided list', () => { 133 | const ids = [1]; 134 | const item = [{}, 2]; 135 | 136 | const result = hasIndex(ids)(...item); 137 | 138 | expect(result).toBeFalsy(); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-dropout 2 | 3 | [![NPM version](https://img.shields.io/npm/v/react-dropout.svg)](https://www.npmjs.com/package/react-dropout) 4 | [![NPM downloads](https://img.shields.io/npm/dm/react-dropout.svg)](https://www.npmjs.com/package/react-dropout) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/886513e64fc6fbc107a7/maintainability)](https://codeclimate.com/github/pawelnvk/react-dropout) 6 | [![Test Coverage](https://api.codeclimate.com/v1/badges/886513e64fc6fbc107a7/test_coverage)](https://codeclimate.com/github/pawelnvk/react-dropout) 7 | [![GitHub Actions](https://github.com/pawelnvk/react-dropout/actions/workflows/PR.yml/badge.svg)](https://github.com/pawelnvk/react-dropout/actions) 8 | 9 | Easy way of managing navigation in react-based apps. 10 | 11 | ## Installation 12 | 13 | ```sh 14 | npm install --save react-dropout 15 | ``` 16 | 17 | ```sh 18 | yarn add react-dropout 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```jsx 24 | const navItems = [ 25 | { exact: true, page: 'Home', path: '/' }, 26 | { page: 'About', path: '/about' }, 27 | { page: 'History', path: '/history' }, 28 | { page: 'Career', path: '/career' }, 29 | { page: 'Blog', path: '/blog' }, 30 | { page: 'Help', path: '/help' }, 31 | { page: 'FAQ', path: '/faq' }, 32 | { page: 'Products', path: '/products' }, 33 | { page: 'Service', path: '/service' }, 34 | { page: 'Articles', path: '/articles' }, 35 | { page: 'Contact', path: '/contact' }, 36 | ]; 37 | 38 | const Hamburger = ({ isActive }) => ( 39 |
40 | 41 | 42 | 43 |
44 | ); 45 | 46 | const Logo = () => ( 47 |
48 | 49 | Logo 50 | 51 |
52 | ); 53 | 54 | const Toggle = ({ children }) => { 55 | const [isToggled, setIsToggled] = React.useState(false); 56 | const handleToggle = () => setIsToggled((previousIsToggled) => !previousIsToggled); 57 | 58 | return ( 59 |
  • 60 | {isToggled && ( 61 | 70 | 71 | {isToggled && children} 72 |
  • 73 | ); 74 | }; 75 | 76 | const Nav = () => ( 77 | 78 | {({ 79 | countToHide, 80 | exceedingItems, 81 | getContentProps, 82 | getRootProps, 83 | items, 84 | }) => ( 85 | 114 | )} 115 | 116 | ); 117 | ``` 118 | 119 | ## Example 120 | 121 | To see example implementation preview [this](https://codesandbox.io/s/wyj7mnz897). 122 | 123 | ## API 124 | 125 | Component accepts following props: 126 | 127 | - `children` - child as a function passing `Droupout` render props 128 | - `items` - list of object to parse that will be available in render props, items can be extended by `grade` property to indicate which of them should be hidden firstly lowest grade is the most important and it will be hidden last 129 | 130 | Render props: 131 | 132 | - `countToHide` - number of hidden elements 133 | - `exceedingItems` - items that are exceeding current container 134 | - `getContentProps` - props that should be attached to content 135 | - `getRootProps` - props that should be attached to root 136 | - `items` - items currently visible 137 | 138 | ## Contributing 139 | 140 | If you want to contribute, please read [contribution guide](CONTRIBUTING.md) 141 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | const { BrowserRouter: Router, Link, Route, Switch } = ReactRouterDOM; 2 | 3 | const root = document.querySelector('#root'); 4 | 5 | const initialNavItems = [ 6 | { exact: true, page: 'Home', path: '/' }, 7 | { page: 'About', path: '/about' }, 8 | { page: 'History', path: '/history' }, 9 | { page: 'Career', path: '/career' }, 10 | { page: 'Blog', path: '/blog' }, 11 | { page: 'Help', path: '/help' }, 12 | { page: 'FAQ', path: '/faq' }, 13 | { page: 'Products', path: '/products' }, 14 | { page: 'Service', path: '/service' }, 15 | { page: 'Articles', path: '/articles' }, 16 | { page: 'Contact', path: '/contact' }, 17 | ]; 18 | 19 | const Hamburger = ({ isActive }) => ( 20 |
    21 | 22 | 23 | 24 |
    25 | ); 26 | 27 | const Logo = () => ( 28 |
    29 | 30 | Logo 31 | 32 |
    33 | ); 34 | 35 | const Toggle = ({ children }) => { 36 | const [isToggled, setIsToggled] = React.useState(false); 37 | const handleToggle = () => setIsToggled((previousIsToggled) => !previousIsToggled); 38 | 39 | return ( 40 |
  • 41 | {isToggled && ( 42 | 51 | 52 | {isToggled && children} 53 |
  • 54 | ); 55 | }; 56 | 57 | const Nav = ({ navItems }) => ( 58 | 59 | {({ 60 | countToHide, 61 | exceedingItems, 62 | getContentProps, 63 | getRootProps, 64 | items, 65 | }) => ( 66 | 95 | )} 96 | 97 | ); 98 | 99 | const App = () => { 100 | const [navItems, setNavItems] = React.useState(initialNavItems); 101 | 102 | return ( 103 | 104 |
    105 |
    106 |
    107 | 108 | 109 |
    111 |
    112 |
    113 | 114 | {navItems.map(({ exact, page, path }) => ( 115 | ( 120 |
    121 |
    122 |

    {page}

    123 |
    124 | 138 | 150 |
    151 |
    152 |
    153 | )} 154 | /> 155 | ))} 156 |
    157 |
    158 |
    159 |
    160 | ); 161 | }; 162 | 163 | ReactDOM.render(, root); 164 | -------------------------------------------------------------------------------- /demo/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | 5 | *, 6 | *:before, 7 | *:after { 8 | box-sizing: inherit; 9 | } 10 | 11 | body { 12 | background-color: #d9d9d9; 13 | color: #3a3c3e; 14 | font-family: 'Open Sans', sans-serif; 15 | } 16 | 17 | .app { 18 | height: 100vh; 19 | } 20 | 21 | .app__main { 22 | height: 100%; 23 | } 24 | 25 | .header { 26 | background-color: #ffffff; 27 | border-bottom: 1px solid #e0e0e0; 28 | display: flex; 29 | left: 0; 30 | position: fixed; 31 | right: 0; 32 | top: 0; 33 | z-index: 10; 34 | } 35 | 36 | .header__container { 37 | align-items: center; 38 | display: flex; 39 | justify-content: space-between; 40 | } 41 | 42 | .logo__link { 43 | color: #f41f0d; 44 | font-size: 18px; 45 | text-decoration: none; 46 | text-transform: uppercase; 47 | } 48 | 49 | .navigation { 50 | text-align: right; 51 | width: 100%; 52 | } 53 | 54 | .navigation__list { 55 | display: inline-flex; 56 | list-style: none; 57 | margin: 0; 58 | padding: 0; 59 | } 60 | 61 | .navigation__item { 62 | display: inline-block; 63 | } 64 | 65 | .navigation__item--toggle { 66 | border-left: 1px solid #e0e0e0; 67 | position: relative; 68 | } 69 | 70 | .navigation__link { 71 | background: none; 72 | border: none; 73 | color: #656565; 74 | cursor: pointer; 75 | display: inline-block; 76 | font-size: 14px; 77 | letter-spacing: 0.11em; 78 | line-height: inherit; 79 | outline: none; 80 | padding: 18px 12px; 81 | text-decoration: none; 82 | text-transform: uppercase; 83 | transition: 0.2s ease color; 84 | -webkit-appearance: none; 85 | } 86 | 87 | .navigation__link--toggle { 88 | align-items: center; 89 | background-color: #fff; 90 | display: flex; 91 | text-align: left; 92 | width: 100px; 93 | } 94 | 95 | .navigation__link:hover { 96 | color: #f41f0d; 97 | } 98 | .navigation__link:hover .hamburger__line { 99 | background-color: #f41f0d; 100 | } 101 | 102 | .subnav { 103 | background-color: #ffffff; 104 | list-style: none; 105 | margin: 0; 106 | min-width: 200px; 107 | padding: 0; 108 | } 109 | 110 | .subnav__item { 111 | border-top: 1px solid #e0e0e0; 112 | } 113 | 114 | .subnav__link { 115 | background: none; 116 | border: none; 117 | color: #656565; 118 | display: block; 119 | font-size: 14px; 120 | letter-spacing: 0.11em; 121 | line-height: inherit; 122 | padding: 18px 12px; 123 | outline: none; 124 | text-align: left; 125 | text-decoration: none; 126 | text-transform: uppercase; 127 | transition: 0.2s ease color; 128 | -webkit-appearance: none; 129 | } 130 | 131 | .subnav__link:hover { 132 | color: #f41f0d; 133 | } 134 | 135 | .page { 136 | align-items: center; 137 | background-size: cover; 138 | display: flex; 139 | height: 100%; 140 | justify-content: center; 141 | position: relative; 142 | } 143 | 144 | .page::before { 145 | background-color: rgba(0, 0, 0, 0.3); 146 | bottom: 0; 147 | content: ''; 148 | left: 0; 149 | position: absolute; 150 | right: 0; 151 | top: 0; 152 | z-index: 0; 153 | } 154 | 155 | .page--home { 156 | background-image: url('https://picsum.photos/1600/900/?image=1065'); 157 | } 158 | 159 | .page--about { 160 | background-image: url('https://picsum.photos/1600/900/?image=1063'); 161 | } 162 | 163 | .page--contact { 164 | background-image: url('https://picsum.photos/1600/900/?image=1067'); 165 | } 166 | 167 | .page--service { 168 | background-image: url('https://picsum.photos/1600/900/?image=1047'); 169 | } 170 | 171 | .page--products { 172 | background-image: url('https://picsum.photos/1600/900/?image=999'); 173 | } 174 | 175 | .page--articles { 176 | background-image: url('https://picsum.photos/1600/900/?image=965'); 177 | } 178 | 179 | .page__content { 180 | position: relative; 181 | z-index: 0; 182 | } 183 | 184 | .page__title { 185 | color: #ffffff; 186 | font-size: 60px; 187 | font-weight: 700; 188 | letter-spacing: 0.15em; 189 | margin: 0 0 20px; 190 | text-align: center; 191 | text-transform: uppercase; 192 | } 193 | 194 | .page__button-wrapper { 195 | display: flex; 196 | flex-wrap: wrap; 197 | margin: -10px 198 | } 199 | 200 | .page__button { 201 | margin: 10px; 202 | } 203 | 204 | .u-container { 205 | margin: auto; 206 | max-width: 1040px; 207 | padding: 0 10px; 208 | width: 100%; 209 | } 210 | 211 | .hamburger { 212 | display: inline-block; 213 | margin-right: 10px; 214 | } 215 | 216 | .hamburger__line { 217 | display: block; 218 | width: 14px; 219 | height: 2px; 220 | background-color: #656565; 221 | margin: 2px auto; 222 | transition: all 0.3s ease-in-out; 223 | } 224 | 225 | .hamburger:hover { 226 | cursor: pointer; 227 | } 228 | 229 | .hamburger.is-active .hamburger__line--2 { 230 | opacity: 0; 231 | } 232 | 233 | .hamburger.is-active .hamburger__line--1 { 234 | transform: translateY(4px) rotate(45deg); 235 | } 236 | 237 | .hamburger.is-active .hamburger__line--3 { 238 | transform: translateY(-4px) rotate(-45deg); 239 | } 240 | 241 | .dropdown__overlay { 242 | background-color: rgba(0, 0, 0, 0.3); 243 | border: none; 244 | bottom: 0; 245 | left: 0; 246 | padding: 0; 247 | position: fixed; 248 | right: 0; 249 | top: 0; 250 | width: 100%; 251 | z-index: 10; 252 | } 253 | 254 | .dropdown__toggle { 255 | position: relative; 256 | z-index: 10; 257 | } 258 | 259 | .dropdown__content { 260 | bottom: 0; 261 | position: fixed; 262 | right: 0; 263 | top: 52px; 264 | z-index: 10; 265 | } 266 | 267 | .button { 268 | background-color: #2596be; 269 | border: 0; 270 | border-radius: 4px; 271 | color: #fff; 272 | cursor: pointer; 273 | font-size: 20px; 274 | font-weight: 700; 275 | letter-spacing: 0.15em; 276 | padding: 10px 20px; 277 | text-transform: uppercase; 278 | } 279 | 280 | .button:hover { 281 | background-color: #29a4d1; 282 | } 283 | --------------------------------------------------------------------------------