├── .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 | [](https://www.npmjs.com/package/react-dropout)
4 | [](https://www.npmjs.com/package/react-dropout)
5 | [](https://codeclimate.com/github/pawelnvk/react-dropout)
6 | [](https://codeclimate.com/github/pawelnvk/react-dropout)
7 | [](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 |
62 | )}
63 |
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 |
43 | )}
44 |
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 |
110 |
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 |
--------------------------------------------------------------------------------