├── .dumirc.ts
├── .editorconfig
├── .eslintrc.js
├── .fatherrc.ts
├── .github
├── dependabot.yml
└── workflows
│ ├── codeql.yml
│ └── main.yml
├── .gitignore
├── .npmignore
├── .prettierignore
├── .prettierrc
├── LICENSE.md
├── README.md
├── assets
└── index.less
├── docs
├── demo
│ ├── basic.md
│ ├── blink.md
│ ├── fill-width.md
│ └── raw-render.md
└── index.md
├── examples
├── basic.tsx
├── blink.tsx
├── common.less
├── fill-width.tsx
└── raw-render.tsx
├── index.js
├── jest.config.js
├── now.json
├── package.json
├── script
└── update-content.js
├── src
├── Item.tsx
├── Overflow.tsx
├── RawItem.tsx
├── context.ts
├── hooks
│ ├── channelUpdate.ts
│ └── useEffectState.tsx
└── index.tsx
├── tests
├── __snapshots__
│ ├── index.spec.tsx.snap
│ ├── invalidate.spec.tsx.snap
│ ├── raw.spec.tsx.snap
│ └── ssr.spec.tsx.snap
├── github.spec.tsx
├── index.spec.tsx
├── invalidate.spec.tsx
├── raw.spec.tsx
├── responsive.spec.tsx
├── setup.js
├── ssr.spec.tsx
└── wrapper.ts
└── tsconfig.json
/.dumirc.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'dumi';
2 | const isGitPagesSite = process.env.GITHUB_ACTIONS;
3 |
4 | export default defineConfig({
5 | favicons: ['https://avatars0.githubusercontent.com/u/9441414?s=200&v=4'],
6 | themeConfig: {
7 | name: 'rc-overflow',
8 | logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4',
9 | },
10 | exportStatic: {},
11 | outputPath: 'docs-dist',
12 | base: isGitPagesSite ? `/rc-overflow/` : `/`,
13 | publicPath: isGitPagesSite ? `/rc-overflow/` : `/`,
14 | styles: [
15 | `
16 | .markdown table {
17 | width: auto !important;
18 | }
19 | `,
20 | ],
21 | });
22 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # top-most EditorConfig file
2 | root = true
3 |
4 | # Unix-style newlines with a newline ending every file
5 | [*.{js,css,md}]
6 | end_of_line = lf
7 | insert_final_newline = true
8 | indent_style = space
9 | indent_size = 2
10 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const base = require('@umijs/fabric/dist/eslint');
2 |
3 | module.exports = {
4 | ...base,
5 | rules: {
6 | ...base.rules,
7 | 'no-template-curly-in-string': 0,
8 | 'prefer-promise-reject-errors': 0,
9 | 'react/no-array-index-key': 0,
10 | 'react/sort-comp': 0,
11 | '@typescript-eslint/no-explicit-any': 0,
12 | },
13 | };
14 |
--------------------------------------------------------------------------------
/.fatherrc.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'father';
2 |
3 | export default defineConfig({
4 | plugins: ['@rc-component/father-plugin'],
5 | });
6 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "weekly"
12 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | name: "CodeQL"
2 |
3 | on:
4 | push:
5 | branches: [ "master" ]
6 | pull_request:
7 | branches: [ "master" ]
8 | schedule:
9 | - cron: "53 19 * * 0"
10 |
11 | jobs:
12 | analyze:
13 | name: Analyze
14 | runs-on: ubuntu-latest
15 | permissions:
16 | actions: read
17 | contents: read
18 | security-events: write
19 |
20 | strategy:
21 | fail-fast: false
22 | matrix:
23 | language: [ javascript ]
24 |
25 | steps:
26 | - name: Checkout
27 | uses: actions/checkout@v3
28 |
29 | - name: Initialize CodeQL
30 | uses: github/codeql-action/init@v2
31 | with:
32 | languages: ${{ matrix.language }}
33 | queries: +security-and-quality
34 |
35 | - name: Autobuild
36 | uses: github/codeql-action/autobuild@v2
37 |
38 | - name: Perform CodeQL Analysis
39 | uses: github/codeql-action/analyze@v2
40 | with:
41 | category: "/language:${{ matrix.language }}"
42 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | jobs:
10 | setup:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: checkout
14 | uses: actions/checkout@master
15 |
16 | - uses: actions/setup-node@v1
17 | with:
18 | node-version: '18'
19 |
20 | - name: cache package-lock.json
21 | uses: actions/cache@v2
22 | with:
23 | path: package-temp-dir
24 | key: lock-${{ github.sha }}
25 |
26 | - name: create package-lock.json
27 | run: npm i --package-lock-only --ignore-scripts
28 |
29 | - name: hack for singe file
30 | run: |
31 | if [ ! -d "package-temp-dir" ]; then
32 | mkdir package-temp-dir
33 | fi
34 | cp package-lock.json package-temp-dir
35 |
36 | - name: cache node_modules
37 | id: node_modules_cache_id
38 | uses: actions/cache@v2
39 | with:
40 | path: node_modules
41 | key: node_modules-${{ hashFiles('**/package-temp-dir/package-lock.json') }}
42 |
43 | - name: install
44 | if: steps.node_modules_cache_id.outputs.cache-hit != 'true'
45 | run: npm ci
46 |
47 | lint:
48 | runs-on: ubuntu-latest
49 | steps:
50 | - name: checkout
51 | uses: actions/checkout@master
52 |
53 | - name: restore cache from package-lock.json
54 | uses: actions/cache@v2
55 | with:
56 | path: package-temp-dir
57 | key: lock-${{ github.sha }}
58 |
59 | - name: restore cache from node_modules
60 | uses: actions/cache@v2
61 | with:
62 | path: node_modules
63 | key: node_modules-${{ hashFiles('**/package-temp-dir/package-lock.json') }}
64 |
65 | - name: lint
66 | run: npm run lint
67 |
68 | needs: setup
69 |
70 | compile:
71 | runs-on: ubuntu-latest
72 | steps:
73 | - name: checkout
74 | uses: actions/checkout@master
75 |
76 | - name: restore cache from package-lock.json
77 | uses: actions/cache@v2
78 | with:
79 | path: package-temp-dir
80 | key: lock-${{ github.sha }}
81 |
82 | - name: restore cache from node_modules
83 | uses: actions/cache@v2
84 | with:
85 | path: node_modules
86 | key: node_modules-${{ hashFiles('**/package-temp-dir/package-lock.json') }}
87 |
88 | - name: compile
89 | run: npm run compile
90 |
91 | needs: setup
92 |
93 | coverage:
94 | runs-on: ubuntu-latest
95 | steps:
96 | - name: checkout
97 | uses: actions/checkout@master
98 |
99 | - name: restore cache from package-lock.json
100 | uses: actions/cache@v2
101 | with:
102 | path: package-temp-dir
103 | key: lock-${{ github.sha }}
104 |
105 | - name: restore cache from node_modules
106 | uses: actions/cache@v2
107 | with:
108 | path: node_modules
109 | key: node_modules-${{ hashFiles('**/package-temp-dir/package-lock.json') }}
110 |
111 | - name: coverage
112 | run: npm test -- --coverage && bash <(curl -s https://codecov.io/bash)
113 |
114 | needs: setup
115 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | *.log
3 | .idea/
4 | .ipr
5 | .iws
6 | *~
7 | ~*
8 | *.diff
9 | *.patch
10 | *.bak
11 | .DS_Store
12 | Thumbs.db
13 | .project
14 | .*proj
15 | .svn/
16 | *.swp
17 | *.swo
18 | *.pyc
19 | *.pyo
20 | .build
21 | node_modules
22 | .cache
23 | assets/**/*.css
24 | build
25 | lib
26 | es
27 | yarn.lock
28 | package-lock.json
29 | coverage/
30 | .doc
31 | dist/
32 |
33 | # dumi
34 | .dumi/tmp
35 | .dumi/tmp-test
36 | .dumi/tmp-production
37 | .env.local
38 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | build/
2 | *.cfg
3 | nohup.out
4 | *.iml
5 | .idea/
6 | .ipr
7 | .iws
8 | *~
9 | ~*
10 | *.diff
11 | *.log
12 | *.patch
13 | *.bak
14 | .DS_Store
15 | Thumbs.db
16 | .project
17 | .*proj
18 | .svn/
19 | *.swp
20 | out/
21 | .build
22 | node_modules
23 | .cache
24 | examples
25 | tests
26 | src
27 | /index.js
28 | .*
29 | assets/**/*.less
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .storybook
2 | node_modules
3 | lib
4 | es
5 | .cache
6 | package.json
7 | package-lock.json
8 | public
9 | .site
10 | _site
11 | .umi
12 | .doc
13 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "endOfLine": "lf",
3 | "semi": true,
4 | "singleQuote": true,
5 | "tabWidth": 2,
6 | "trailingComma": "all",
7 | "arrowParens": "avoid"
8 | }
9 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2019-present afc163
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # rc-overflow 🐾
2 |
3 | [![NPM version][npm-image]][npm-url]
4 | [![npm download][download-image]][download-url]
5 | [![build status][github-actions-image]][github-actions-url]
6 | [![Codecov][codecov-image]][codecov-url]
7 | [![bundle size][bundlephobia-image]][bundlephobia-url]
8 | [![dumi][dumi-image]][dumi-url]
9 |
10 | [npm-image]: http://img.shields.io/npm/v/rc-overflow.svg?style=flat-square
11 | [npm-url]: http://npmjs.org/package/rc-overflow
12 | [github-actions-image]: https://github.com/react-component/overflow/workflows/CI/badge.svg
13 | [github-actions-url]: https://github.com/react-component/overflow/actions
14 | [codecov-image]: https://img.shields.io/codecov/c/github/react-component/overflow/master.svg?style=flat-square
15 | [codecov-url]: https://codecov.io/gh/react-component/overflow/branch/master
16 | [david-url]: https://david-dm.org/react-component/overflow
17 | [david-image]: https://david-dm.org/react-component/overflow/status.svg?style=flat-square
18 | [david-dev-url]: https://david-dm.org/react-component/overflow?type=dev
19 | [david-dev-image]: https://david-dm.org/react-component/overflow/dev-status.svg?style=flat-square
20 | [download-image]: https://img.shields.io/npm/dm/rc-overflow.svg?style=flat-square
21 | [download-url]: https://npmjs.org/package/rc-overflow
22 | [bundlephobia-url]: https://bundlephobia.com/result?p=rc-overflow
23 | [bundlephobia-image]: https://badgen.net/bundlephobia/minzip/rc-overflow
24 | [dumi-url]: https://github.com/umijs/dumi
25 | [dumi-image]: https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square
26 |
27 | Auto collapse box when overflow
28 |
29 | ## Live Demo
30 |
31 | https://overflow-react-component.vercel.app/
32 |
33 | ## Install
34 |
35 | [](https://npmjs.org/package/rc-overflow)
36 |
37 | ## Usage
38 |
39 | ```ts
40 | // TODO
41 | ```
42 |
43 | ## API
44 |
45 | | Property | Type | Default | Description |
46 | | -------- | ---- | ------- | ----------- |
47 |
48 | ## Development
49 |
50 | ```
51 | npm install
52 | npm start
53 | ```
54 |
55 | ## License
56 |
57 | rc-overflow is released under the MIT license.
58 |
--------------------------------------------------------------------------------
/assets/index.less:
--------------------------------------------------------------------------------
1 | @overflow-prefix-cls: rc-overflow;
2 |
3 | .@{overflow-prefix-cls} {
4 | display: flex;
5 | flex-wrap: wrap;
6 | max-width: 100%;
7 | position: relative;
8 |
9 | &-item {
10 | background: rgba(0, 255, 0, 0.2);
11 | box-shadow: 0 0 1px black;
12 | flex: none;
13 | max-width: 100%;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/docs/demo/basic.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: basic
3 | nav:
4 | title: Demo
5 | path: /demo
6 | ---
7 |
8 |
9 |
--------------------------------------------------------------------------------
/docs/demo/blink.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: blink
3 | nav:
4 | title: Demo
5 | path: /demo
6 | ---
7 |
8 |
9 |
--------------------------------------------------------------------------------
/docs/demo/fill-width.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: fill-width
3 | nav:
4 | title: Demo
5 | path: /demo
6 | ---
7 |
8 |
9 |
--------------------------------------------------------------------------------
/docs/demo/raw-render.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: raw-render
3 | nav:
4 | title: Demo
5 | path: /demo
6 | ---
7 |
8 |
9 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | hero:
3 | title: rc-overflow
4 | description: React Overflow Component
5 | ---
6 |
7 |
8 |
--------------------------------------------------------------------------------
/examples/basic.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Overflow from '../src';
3 | import '../assets/index.less';
4 | import './common.less';
5 |
6 | interface ItemType {
7 | value: string | number;
8 | label: string;
9 | }
10 |
11 | function createData(count: number): ItemType[] {
12 | const data: ItemType[] = new Array(count).fill(undefined).map((_, index) => ({
13 | value: index,
14 | label: `Label ${index}`,
15 | }));
16 |
17 | return data;
18 | }
19 |
20 | function renderItem(item: ItemType) {
21 | return (
22 |
29 | {item.label}
30 |
31 | );
32 | }
33 |
34 | function renderRest(items: ItemType[]) {
35 | return (
36 |
43 | +{items.length}...
44 |
45 | );
46 | }
47 |
48 | const Demo = () => {
49 | const [responsive, setResponsive] = React.useState(true);
50 | const [data, setData] = React.useState(createData(1));
51 |
52 | return (
53 |
54 |
62 |
78 |
79 |
86 |
87 |
96 |
97 | data={data}
98 | renderItem={renderItem}
99 | renderRest={renderRest}
100 | maxCount={responsive ? 'responsive' : 6}
101 | // suffix={1}
102 | />
103 |
104 |
105 | );
106 | };
107 |
108 | export default Demo;
109 |
--------------------------------------------------------------------------------
/examples/blink.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Overflow from '../src';
3 | import '../assets/index.less';
4 | import './common.less';
5 |
6 | const overflowSharedStyle: React.CSSProperties = {
7 | background: 'rgba(0, 255, 0, 0.1)',
8 | };
9 |
10 | interface ItemType {
11 | value: string | number;
12 | label: string;
13 | }
14 |
15 | function createData(count: number): ItemType[] {
16 | const data: ItemType[] = new Array(count).fill(undefined).map((_, index) => ({
17 | value: index,
18 | label: `Label ${index}`,
19 | }));
20 |
21 | return data;
22 | }
23 |
24 | const sharedStyle: React.CSSProperties = {
25 | padding: '4px 8px',
26 | width: 90,
27 | overflow: 'hidden',
28 | background: 'rgba(255, 0, 0, 0.2)',
29 | };
30 |
31 | function renderItem(item: ItemType) {
32 | return {item.label}
;
33 | }
34 |
35 | function renderRest(items: ItemType[]) {
36 | if (items.length === 3) {
37 | return items.length;
38 | }
39 |
40 | return +{items.length}...
;
41 | }
42 |
43 | const data = createData(5);
44 | const data2 = createData(2);
45 |
46 | const Demo = () => {
47 | return (
48 |
49 |
50 | Test for a edge case that rest can not decide the final display count
51 |
52 |
59 |
60 | data={data}
61 | style={{ width: 300, ...overflowSharedStyle }}
62 | renderItem={renderItem}
63 | renderRest={renderRest}
64 | maxCount="responsive"
65 | />
66 |
67 |
68 | data={data2}
69 | style={{ width: 180, ...overflowSharedStyle }}
70 | renderItem={renderItem}
71 | renderRest={renderRest}
72 | maxCount="responsive"
73 | />
74 |
75 |
76 | );
77 | };
78 |
79 | export default Demo;
80 |
--------------------------------------------------------------------------------
/examples/common.less:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
--------------------------------------------------------------------------------
/examples/fill-width.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import useLayoutEffect from "rc-util/lib/hooks/useLayoutEffect";
3 | import Overflow from '../src';
4 | import '../assets/index.less';
5 | import './common.less';
6 |
7 | interface ItemType {
8 | value: string | number;
9 | label: string;
10 | }
11 |
12 | function createData(count: number): ItemType[] {
13 | const data: ItemType[] = new Array(count).fill(undefined).map((_, index) => ({
14 | value: index,
15 | label: `Label ${index}`,
16 | }));
17 |
18 | return data;
19 | }
20 |
21 | function renderItem(item: ItemType) {
22 | return (
23 |
30 | {item.label}
31 |
32 | );
33 | }
34 |
35 | function renderRest(items: ItemType[]) {
36 | return (
37 |
44 | +{items.length}...
45 |
46 | );
47 | }
48 |
49 | const inputStyle: React.CSSProperties = {
50 | border: 'none',
51 | fontSize: 12,
52 | margin: 0,
53 | outline: 'none',
54 | lineHeight: '20px',
55 | fontFamily: '-apple-system',
56 | padding: '0 4px',
57 | };
58 |
59 | const Demo = () => {
60 | const [responsive, setResponsive] = React.useState(true);
61 | const [inputValue, setInputValue] = React.useState('');
62 | const [inputWidth, setInputWidth] = React.useState(0);
63 | const [data, setData] = React.useState(createData(3));
64 | const inputRef = React.useRef();
65 | const measureRef = React.useRef();
66 |
67 | useLayoutEffect(() => {
68 | setInputWidth(measureRef.current.offsetWidth);
69 | }, [inputValue]);
70 |
71 | React.useEffect(() => {
72 | inputRef.current.focus();
73 | }, []);
74 |
75 | return (
76 |
77 |
85 |
101 |
102 |
110 |
111 | data={data}
112 | renderItem={renderItem}
113 | renderRest={renderRest}
114 | maxCount={responsive ? 'responsive' : 6}
115 | suffix={
116 |
117 |
{
127 | setInputValue(e.target.value);
128 | }}
129 | ref={inputRef}
130 | />
131 |
141 | {inputValue}
142 |
143 |
144 | }
145 | />
146 |
147 |
148 | );
149 | };
150 |
151 | export default Demo;
152 |
--------------------------------------------------------------------------------
/examples/raw-render.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Overflow from '../src';
3 | import '../assets/index.less';
4 | import './common.less';
5 |
6 | interface ItemType {
7 | value: string | number;
8 | label: string;
9 | }
10 |
11 | function createData(count: number): ItemType[] {
12 | const data: ItemType[] = new Array(count).fill(undefined).map((_, index) => ({
13 | value: index,
14 | label: `Label ${index}`,
15 | }));
16 |
17 | return data;
18 | }
19 |
20 | function renderRawItem(item: ItemType) {
21 | return (
22 |
23 |
30 | {item.label}
31 |
32 |
33 | );
34 | }
35 |
36 | function renderRest(items: ItemType[]) {
37 | return (
38 |
45 | +{items.length}...
46 |
47 | );
48 | }
49 |
50 | const Demo = () => {
51 | const [responsive, setResponsive] = React.useState(true);
52 | const [data, setData] = React.useState(createData(1));
53 |
54 | return (
55 |
56 |
64 |
80 |
81 |
89 |
90 | data={data}
91 | renderRawItem={renderRawItem}
92 | renderRest={renderRest}
93 | maxCount={responsive ? 'responsive' : 6}
94 | />
95 |
96 |
97 | );
98 | };
99 |
100 | export default Demo;
101 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | module.exports = require('./src/');
2 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | setupFiles: ['./tests/setup.js'],
3 | snapshotSerializers: [require.resolve('enzyme-to-json/serializer')],
4 | };
5 |
--------------------------------------------------------------------------------
/now.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 2,
3 | "name": "rc-overflow",
4 | "builds": [
5 | {
6 | "src": "package.json",
7 | "use": "@vercel/static-build",
8 | "config": { "distDir": "docs-dist" }
9 | }
10 | ]
11 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rc-overflow",
3 | "version": "1.4.1",
4 | "description": "Auto collapse box when overflow",
5 | "keywords": [
6 | "react",
7 | "react-component",
8 | "react-overflow",
9 | "overflow",
10 | "antd",
11 | "ant-design"
12 | ],
13 | "main": "./lib/index",
14 | "module": "./es/index",
15 | "files": [
16 | "assets/*.css",
17 | "assets/*.less",
18 | "es",
19 | "lib",
20 | "dist"
21 | ],
22 | "homepage": "https://react-component.github.io/overflow",
23 | "repository": {
24 | "type": "git",
25 | "url": "git@github.com:react-component/overflow.git"
26 | },
27 | "bugs": {
28 | "url": "http://github.com/react-component/overflow/issues"
29 | },
30 | "license": "MIT",
31 | "scripts": {
32 | "start": "dumi dev",
33 | "docs:build": "dumi build",
34 | "docs:deploy": "gh-pages -d docs-dist",
35 | "compile": "father build",
36 | "prepare": "dumi setup",
37 | "deploy": "npm run docs:build && npm run docs:deploy",
38 | "prettier": "prettier --write \"**/*.{js,jsx,tsx,ts,less,md,json}\"",
39 | "test": "rc-test",
40 | "test:coverage": "rc-test --coverage",
41 | "prepublishOnly": "npm run compile && np --no-cleanup --yolo --no-publish",
42 | "lint": "eslint src/ --ext .tsx,.ts",
43 | "lint:tsc": "tsc -p tsconfig.json --noEmit",
44 | "now-build": "npm run docs:build"
45 | },
46 | "dependencies": {
47 | "@babel/runtime": "^7.11.1",
48 | "classnames": "^2.2.1",
49 | "rc-resize-observer": "^1.0.0",
50 | "rc-util": "^5.37.0"
51 | },
52 | "devDependencies": {
53 | "@rc-component/father-plugin": "^1.0.0",
54 | "@testing-library/jest-dom": "^5.16.4",
55 | "@testing-library/react": "^12.0.0",
56 | "@types/classnames": "^2.2.9",
57 | "@types/enzyme": "^3.10.8",
58 | "@types/jest": "^26.0.23",
59 | "@types/react": "^16.14.2",
60 | "@types/react-dom": "^16.9.10",
61 | "@umijs/fabric": "^3.0.0",
62 | "glob": "^10.0.0",
63 | "cross-env": "^7.0.2",
64 | "dumi": "^2.0.0",
65 | "enzyme": "^3.0.0",
66 | "enzyme-adapter-react-16": "^1.0.1",
67 | "enzyme-to-json": "^3.4.0",
68 | "eslint": "^7.0.0",
69 | "father": "^4.0.0",
70 | "less": "^3.10.3",
71 | "np": "^7.0.0",
72 | "prettier": "^2.0.5",
73 | "rc-test": "^7.0",
74 | "react": "^16.0.0",
75 | "react-dom": "^16.0.0",
76 | "regenerator-runtime": "^0.13.7",
77 | "typescript": "^5.0.0"
78 | },
79 | "peerDependencies": {
80 | "react": ">=16.9.0",
81 | "react-dom": ">=16.9.0"
82 | },
83 | "overrides": {
84 | "cheerio": "1.0.0-rc.12"
85 | },
86 | "cnpm": {
87 | "mode": "npm"
88 | },
89 | "tnpm": {
90 | "mode": "npm"
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/script/update-content.js:
--------------------------------------------------------------------------------
1 | /*
2 | 用于 dumi 改造使用,
3 | 可用于将 examples 的文件批量修改为 demo 引入形式,
4 | 其他项目根据具体情况使用。
5 | */
6 |
7 | const fs = require('fs');
8 | const glob = require('glob');
9 |
10 | const paths = glob.sync('./examples/*.tsx');
11 |
12 | paths.forEach(path => {
13 | const name = path.split('/').pop().split('.')[0];
14 | fs.writeFile(
15 | `./docs/demo/${name}.md`,
16 | `---
17 | title: ${name}
18 | nav:
19 | title: Demo
20 | path: /demo
21 | ---
22 |
23 |
24 | `,
25 | 'utf8',
26 | function(error) {
27 | if(error){
28 | console.log(error);
29 | return false;
30 | }
31 | console.log(`${name} 更新成功~`);
32 | }
33 | )
34 | });
35 |
--------------------------------------------------------------------------------
/src/Item.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import classNames from 'classnames';
3 | import ResizeObserver from 'rc-resize-observer';
4 | import type { ComponentType } from './RawItem';
5 |
6 | // Use shared variable to save bundle size
7 | const UNDEFINED = undefined;
8 |
9 | export interface ItemProps extends React.HTMLAttributes {
10 | prefixCls: string;
11 | item?: ItemType;
12 | className?: string;
13 | style?: React.CSSProperties;
14 | renderItem?: (item: ItemType, info: { index: number }) => React.ReactNode;
15 | responsive?: boolean;
16 | // https://github.com/ant-design/ant-design/issues/35475
17 | /**
18 | * @private To make node structure stable. We need keep wrap with ResizeObserver.
19 | * But disable it when it's no need to real measure.
20 | */
21 | responsiveDisabled?: boolean;
22 | itemKey?: React.Key;
23 | registerSize: (key: React.Key, width: number | null) => void;
24 | children?: React.ReactNode;
25 | display: boolean;
26 | order: number;
27 | component?: ComponentType;
28 | invalidate?: boolean;
29 | }
30 |
31 | function InternalItem(
32 | props: ItemProps,
33 | ref: React.Ref,
34 | ) {
35 | const {
36 | prefixCls,
37 | invalidate,
38 | item,
39 | renderItem,
40 | responsive,
41 | responsiveDisabled,
42 | registerSize,
43 | itemKey,
44 | className,
45 | style,
46 | children,
47 | display,
48 | order,
49 | component: Component = 'div',
50 | ...restProps
51 | } = props;
52 |
53 | const mergedHidden = responsive && !display;
54 |
55 | // ================================ Effect ================================
56 | function internalRegisterSize(width: number | null) {
57 | registerSize(itemKey!, width);
58 | }
59 |
60 | React.useEffect(
61 | () => () => {
62 | internalRegisterSize(null);
63 | },
64 | [],
65 | );
66 |
67 | // ================================ Render ================================
68 | const childNode =
69 | renderItem && item !== UNDEFINED ? renderItem(item, { index: order }) : children;
70 |
71 | let overflowStyle: React.CSSProperties | undefined;
72 | if (!invalidate) {
73 | overflowStyle = {
74 | opacity: mergedHidden ? 0 : 1,
75 | height: mergedHidden ? 0 : UNDEFINED,
76 | overflowY: mergedHidden ? 'hidden' : UNDEFINED,
77 | order: responsive ? order : UNDEFINED,
78 | pointerEvents: mergedHidden ? 'none' : UNDEFINED,
79 | position: mergedHidden ? 'absolute' : UNDEFINED,
80 | };
81 | }
82 |
83 | const overflowProps: React.HTMLAttributes = {};
84 | if (mergedHidden) {
85 | overflowProps['aria-hidden'] = true;
86 | }
87 |
88 | let itemNode = (
89 |
99 | {childNode}
100 |
101 | );
102 |
103 | if (responsive) {
104 | itemNode = (
105 | {
107 | internalRegisterSize(offsetWidth);
108 | }}
109 | disabled={responsiveDisabled}
110 | >
111 | {itemNode}
112 |
113 | );
114 | }
115 |
116 | return itemNode;
117 | }
118 |
119 | const Item = React.forwardRef(InternalItem);
120 | Item.displayName = 'Item';
121 |
122 | export default Item;
123 |
--------------------------------------------------------------------------------
/src/Overflow.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useState, useMemo, useCallback } from 'react';
3 | import classNames from 'classnames';
4 | import ResizeObserver from 'rc-resize-observer';
5 | import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect';
6 | import Item from './Item';
7 | import useEffectState, { useBatcher } from './hooks/useEffectState';
8 | import type { ComponentType } from './RawItem';
9 | import RawItem from './RawItem';
10 | import { OverflowContext } from './context';
11 |
12 | const RESPONSIVE = 'responsive' as const;
13 | const INVALIDATE = 'invalidate' as const;
14 |
15 | export { OverflowContext } from './context';
16 |
17 | export type { ComponentType } from './RawItem';
18 |
19 | export interface OverflowProps extends React.HTMLAttributes {
20 | prefixCls?: string;
21 | className?: string;
22 | style?: React.CSSProperties;
23 | data?: ItemType[];
24 | itemKey?: React.Key | ((item: ItemType) => React.Key);
25 | /** Used for `responsive`. It will limit render node to avoid perf issue */
26 | itemWidth?: number;
27 | renderItem?: (item: ItemType, info: { index: number }) => React.ReactNode;
28 | /** @private Do not use in your production. Render raw node that need wrap Item by developer self */
29 | renderRawItem?: (item: ItemType, index: number) => React.ReactElement;
30 | maxCount?: number | typeof RESPONSIVE | typeof INVALIDATE;
31 | renderRest?:
32 | | React.ReactNode
33 | | ((omittedItems: ItemType[]) => React.ReactNode);
34 | /** @private Do not use in your production. Render raw node that need wrap Item by developer self */
35 | renderRawRest?: (omittedItems: ItemType[]) => React.ReactElement;
36 | suffix?: React.ReactNode;
37 | component?: ComponentType;
38 | itemComponent?: ComponentType;
39 |
40 | /** @private This API may be refactor since not well design */
41 | onVisibleChange?: (visibleCount: number) => void;
42 |
43 | /** When set to `full`, ssr will render full items by default and remove at client side */
44 | ssr?: 'full';
45 | }
46 |
47 | function defaultRenderRest(omittedItems: ItemType[]) {
48 | return `+ ${omittedItems.length} ...`;
49 | }
50 |
51 | function Overflow(
52 | props: OverflowProps,
53 | ref: React.Ref,
54 | ) {
55 | const {
56 | prefixCls = 'rc-overflow',
57 | data = [],
58 | renderItem,
59 | renderRawItem,
60 | itemKey,
61 | itemWidth = 10,
62 | ssr,
63 | style,
64 | className,
65 | maxCount,
66 | renderRest,
67 | renderRawRest,
68 | suffix,
69 | component: Component = 'div',
70 | itemComponent,
71 | onVisibleChange,
72 | ...restProps
73 | } = props;
74 |
75 | const fullySSR = ssr === 'full';
76 |
77 | const notifyEffectUpdate = useBatcher();
78 |
79 | const [containerWidth, setContainerWidth] = useEffectState(
80 | notifyEffectUpdate,
81 | null,
82 | );
83 | const mergedContainerWidth = containerWidth || 0;
84 |
85 | const [itemWidths, setItemWidths] = useEffectState(
86 | notifyEffectUpdate,
87 | new Map(),
88 | );
89 |
90 | const [prevRestWidth, setPrevRestWidth] = useEffectState(
91 | notifyEffectUpdate,
92 | 0,
93 | );
94 | const [restWidth, setRestWidth] = useEffectState(
95 | notifyEffectUpdate,
96 | 0,
97 | );
98 |
99 | const [suffixWidth, setSuffixWidth] = useEffectState(
100 | notifyEffectUpdate,
101 | 0,
102 | );
103 | const [suffixFixedStart, setSuffixFixedStart] = useState(null);
104 |
105 | const [displayCount, setDisplayCount] = useState(null);
106 | const mergedDisplayCount = React.useMemo(() => {
107 | if (displayCount === null && fullySSR) {
108 | return Number.MAX_SAFE_INTEGER;
109 | }
110 |
111 | return displayCount || 0;
112 | }, [displayCount, containerWidth]);
113 |
114 | const [restReady, setRestReady] = useState(false);
115 |
116 | const itemPrefixCls = `${prefixCls}-item`;
117 |
118 | // Always use the max width to avoid blink
119 | const mergedRestWidth = Math.max(prevRestWidth, restWidth);
120 |
121 | // ================================= Data =================================
122 | const isResponsive = maxCount === RESPONSIVE;
123 | const shouldResponsive = data.length && isResponsive;
124 | const invalidate = maxCount === INVALIDATE;
125 |
126 | /**
127 | * When is `responsive`, we will always render rest node to get the real width of it for calculation
128 | */
129 | const showRest =
130 | shouldResponsive ||
131 | (typeof maxCount === 'number' && data.length > maxCount);
132 |
133 | const mergedData = useMemo(() => {
134 | let items = data;
135 |
136 | if (shouldResponsive) {
137 | if (containerWidth === null && fullySSR) {
138 | items = data;
139 | } else {
140 | items = data.slice(
141 | 0,
142 | Math.min(data.length, mergedContainerWidth / itemWidth),
143 | );
144 | }
145 | } else if (typeof maxCount === 'number') {
146 | items = data.slice(0, maxCount);
147 | }
148 |
149 | return items;
150 | }, [data, itemWidth, containerWidth, maxCount, shouldResponsive]);
151 |
152 | const omittedItems = useMemo(() => {
153 | if (shouldResponsive) {
154 | return data.slice(mergedDisplayCount + 1);
155 | }
156 | return data.slice(mergedData.length);
157 | }, [data, mergedData, shouldResponsive, mergedDisplayCount]);
158 |
159 | // ================================= Item =================================
160 | const getKey = useCallback(
161 | (item: ItemType, index: number) => {
162 | if (typeof itemKey === 'function') {
163 | return itemKey(item);
164 | }
165 | return (itemKey && (item as any)?.[itemKey]) ?? index;
166 | },
167 | [itemKey],
168 | );
169 |
170 | const mergedRenderItem = useCallback(
171 | renderItem || ((item: ItemType) => item),
172 | [renderItem],
173 | );
174 |
175 | function updateDisplayCount(
176 | count: number,
177 | suffixFixedStartVal: number,
178 | notReady?: boolean,
179 | ) {
180 | // React 18 will sync render even when the value is same in some case.
181 | // We take `mergedData` as deps which may cause dead loop if it's dynamic generate.
182 | // ref: https://github.com/ant-design/ant-design/issues/36559
183 | if (
184 | displayCount === count &&
185 | (suffixFixedStartVal === undefined ||
186 | suffixFixedStartVal === suffixFixedStart)
187 | ) {
188 | return;
189 | }
190 |
191 | setDisplayCount(count);
192 | if (!notReady) {
193 | setRestReady(count < data.length - 1);
194 |
195 | onVisibleChange?.(count);
196 | }
197 |
198 | if (suffixFixedStartVal !== undefined) {
199 | setSuffixFixedStart(suffixFixedStartVal);
200 | }
201 | }
202 |
203 | // ================================= Size =================================
204 | function onOverflowResize(_: object, element: HTMLElement) {
205 | setContainerWidth(element.clientWidth);
206 | }
207 |
208 | function registerSize(key: React.Key, width: number | null) {
209 | setItemWidths(origin => {
210 | const clone = new Map(origin);
211 |
212 | if (width === null) {
213 | clone.delete(key);
214 | } else {
215 | clone.set(key, width);
216 | }
217 | return clone;
218 | });
219 | }
220 |
221 | function registerOverflowSize(_: React.Key, width: number | null) {
222 | setRestWidth(width!);
223 | setPrevRestWidth(restWidth);
224 | }
225 |
226 | function registerSuffixSize(_: React.Key, width: number | null) {
227 | setSuffixWidth(width!);
228 | }
229 |
230 | // ================================ Effect ================================
231 | function getItemWidth(index: number) {
232 | return itemWidths.get(getKey(mergedData[index], index));
233 | }
234 |
235 | useLayoutEffect(() => {
236 | if (
237 | mergedContainerWidth &&
238 | typeof mergedRestWidth === 'number' &&
239 | mergedData
240 | ) {
241 | let totalWidth = suffixWidth;
242 |
243 | const len = mergedData.length;
244 | const lastIndex = len - 1;
245 |
246 | // When data count change to 0, reset this since not loop will reach
247 | if (!len) {
248 | updateDisplayCount(0, null);
249 | return;
250 | }
251 |
252 | for (let i = 0; i < len; i += 1) {
253 | let currentItemWidth = getItemWidth(i);
254 |
255 | // Fully will always render
256 | if (fullySSR) {
257 | currentItemWidth = currentItemWidth || 0;
258 | }
259 |
260 | // Break since data not ready
261 | if (currentItemWidth === undefined) {
262 | updateDisplayCount(i - 1, undefined, true);
263 | break;
264 | }
265 |
266 | // Find best match
267 | totalWidth += currentItemWidth;
268 |
269 | if (
270 | // Only one means `totalWidth` is the final width
271 | (lastIndex === 0 && totalWidth <= mergedContainerWidth) ||
272 | // Last two width will be the final width
273 | (i === lastIndex - 1 &&
274 | totalWidth + getItemWidth(lastIndex)! <= mergedContainerWidth)
275 | ) {
276 | // Additional check if match the end
277 | updateDisplayCount(lastIndex, null);
278 | break;
279 | } else if (totalWidth + mergedRestWidth > mergedContainerWidth) {
280 | // Can not hold all the content to show rest
281 | updateDisplayCount(
282 | i - 1,
283 | totalWidth - currentItemWidth - suffixWidth + restWidth,
284 | );
285 | break;
286 | }
287 | }
288 |
289 | if (suffix && getItemWidth(0) + suffixWidth > mergedContainerWidth) {
290 | setSuffixFixedStart(null);
291 | }
292 | }
293 | }, [
294 | mergedContainerWidth,
295 | itemWidths,
296 | restWidth,
297 | suffixWidth,
298 | getKey,
299 | mergedData,
300 | ]);
301 |
302 | // ================================ Render ================================
303 | const displayRest = restReady && !!omittedItems.length;
304 |
305 | let suffixStyle: React.CSSProperties = {};
306 | if (suffixFixedStart !== null && shouldResponsive) {
307 | suffixStyle = {
308 | position: 'absolute',
309 | left: suffixFixedStart,
310 | top: 0,
311 | };
312 | }
313 |
314 | const itemSharedProps = {
315 | prefixCls: itemPrefixCls,
316 | responsive: shouldResponsive,
317 | component: itemComponent,
318 | invalidate,
319 | };
320 |
321 | // >>>>> Choice render fun by `renderRawItem`
322 | const internalRenderItemNode = renderRawItem
323 | ? (item: ItemType, index: number) => {
324 | const key = getKey(item, index);
325 |
326 | return (
327 |
338 | {renderRawItem(item, index)}
339 |
340 | );
341 | }
342 | : (item: ItemType, index: number) => {
343 | const key = getKey(item, index);
344 |
345 | return (
346 |
356 | );
357 | };
358 |
359 | // >>>>> Rest node
360 | const restContextProps = {
361 | order: displayRest ? mergedDisplayCount : Number.MAX_SAFE_INTEGER,
362 | className: `${itemPrefixCls}-rest`,
363 | registerSize: registerOverflowSize,
364 | display: displayRest,
365 | };
366 |
367 | const mergedRenderRest = renderRest || defaultRenderRest;
368 |
369 | const restNode = renderRawRest ? (
370 |
376 | {renderRawRest(omittedItems)}
377 |
378 | ) : (
379 | -
384 | {typeof mergedRenderRest === 'function'
385 | ? mergedRenderRest(omittedItems)
386 | : mergedRenderRest}
387 |
388 | );
389 |
390 | const overflowNode = (
391 |
397 | {mergedData.map(internalRenderItemNode)}
398 |
399 | {/* Rest Count Item */}
400 | {showRest ? restNode : null}
401 |
402 | {/* Suffix Node */}
403 | {suffix && (
404 | -
414 | {suffix}
415 |
416 | )}
417 |
418 | );
419 |
420 | return isResponsive ? (
421 |
422 | {overflowNode}
423 |
424 | ) : overflowNode;
425 | }
426 |
427 | const ForwardOverflow = React.forwardRef(Overflow);
428 |
429 | type ForwardOverflowType = (
430 | props: React.PropsWithChildren> & {
431 | ref?: React.Ref;
432 | },
433 | ) => React.ReactElement;
434 |
435 | type FilledOverflowType = ForwardOverflowType & {
436 | Item: typeof RawItem;
437 | RESPONSIVE: typeof RESPONSIVE;
438 | /** Will work as normal `component`. Skip patch props like `prefixCls`. */
439 | INVALIDATE: typeof INVALIDATE;
440 | };
441 |
442 | ForwardOverflow.displayName = 'Overflow';
443 |
444 | (ForwardOverflow as unknown as FilledOverflowType).Item = RawItem;
445 | (ForwardOverflow as unknown as FilledOverflowType).RESPONSIVE = RESPONSIVE;
446 | (ForwardOverflow as unknown as FilledOverflowType).INVALIDATE = INVALIDATE;
447 |
448 | // Convert to generic type
449 | export default ForwardOverflow as unknown as FilledOverflowType;
450 |
--------------------------------------------------------------------------------
/src/RawItem.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import classNames from 'classnames';
3 | import Item from './Item';
4 | import { OverflowContext } from './context';
5 |
6 | export type ComponentType =
7 | | React.ComponentType
8 | | React.ForwardRefExoticComponent
9 | | React.FC
10 | | keyof React.ReactHTML;
11 |
12 | export interface RawItemProps extends React.HTMLAttributes {
13 | component?: ComponentType;
14 | children?: React.ReactNode;
15 | }
16 |
17 | const InternalRawItem = (props: RawItemProps, ref: React.Ref) => {
18 | const context = React.useContext(OverflowContext);
19 |
20 | // Render directly when context not provided
21 | if (!context) {
22 | const { component: Component = 'div', ...restProps } = props;
23 | return ;
24 | }
25 |
26 | const { className: contextClassName, ...restContext } = context;
27 | const { className, ...restProps } = props;
28 |
29 | // Do not pass context to sub item to avoid multiple measure
30 | return (
31 |
32 |
38 |
39 | );
40 | };
41 |
42 | const RawItem = React.forwardRef(InternalRawItem);
43 | RawItem.displayName = 'RawItem';
44 |
45 | export default RawItem;
46 |
--------------------------------------------------------------------------------
/src/context.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export const OverflowContext = React.createContext<{
4 | prefixCls: string;
5 | responsive: boolean;
6 | order: number;
7 | registerSize: (key: React.Key, width: number | null) => void;
8 | display: boolean;
9 |
10 | invalidate: boolean;
11 |
12 | // Item Usage
13 | item?: any;
14 | itemKey?: React.Key;
15 |
16 | // Rest Usage
17 | className?: string;
18 | }>(null);
19 |
--------------------------------------------------------------------------------
/src/hooks/channelUpdate.ts:
--------------------------------------------------------------------------------
1 | import raf from 'rc-util/lib/raf';
2 |
3 | export default function channelUpdate(callback: VoidFunction) {
4 | if (typeof MessageChannel === 'undefined') {
5 | raf(callback);
6 | } else {
7 | const channel = new MessageChannel();
8 | channel.port1.onmessage = () => callback();
9 | channel.port2.postMessage(undefined);
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/hooks/useEffectState.tsx:
--------------------------------------------------------------------------------
1 | import useEvent from 'rc-util/lib/hooks/useEvent';
2 | import * as React from 'react';
3 | import { unstable_batchedUpdates } from 'react-dom';
4 | import channelUpdate from './channelUpdate';
5 |
6 | type Updater = T | ((origin: T) => T);
7 |
8 | type UpdateCallbackFunc = VoidFunction;
9 |
10 | type NotifyEffectUpdate = (callback: UpdateCallbackFunc) => void;
11 |
12 | /**
13 | * Batcher for record any `useEffectState` need update.
14 | */
15 | export function useBatcher() {
16 | // Updater Trigger
17 | const updateFuncRef = React.useRef(null);
18 |
19 | // Notify update
20 | const notifyEffectUpdate: NotifyEffectUpdate = callback => {
21 | if (!updateFuncRef.current) {
22 | updateFuncRef.current = [];
23 |
24 | channelUpdate(() => {
25 | unstable_batchedUpdates(() => {
26 | updateFuncRef.current.forEach(fn => {
27 | fn();
28 | });
29 | updateFuncRef.current = null;
30 | });
31 | });
32 | }
33 |
34 | updateFuncRef.current.push(callback);
35 | };
36 |
37 | return notifyEffectUpdate;
38 | }
39 |
40 | /**
41 | * Trigger state update by `useLayoutEffect` to save perf.
42 | */
43 | export default function useEffectState(
44 | notifyEffectUpdate: NotifyEffectUpdate,
45 | defaultValue?: T,
46 | ): [T, (value: Updater) => void] {
47 | // Value
48 | const [stateValue, setStateValue] = React.useState(defaultValue);
49 |
50 | // Set State
51 | const setEffectVal = useEvent((nextValue: Updater) => {
52 | notifyEffectUpdate(() => {
53 | setStateValue(nextValue);
54 | });
55 | });
56 |
57 | return [stateValue, setEffectVal];
58 | }
59 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import Overflow from './Overflow';
2 | import type { OverflowProps } from './Overflow';
3 |
4 | export type { OverflowProps };
5 |
6 | export default Overflow;
7 |
--------------------------------------------------------------------------------
/tests/__snapshots__/index.spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Overflow.Basic customize component 1`] = `
4 |
7 | -
11 | Label 0
12 |
13 |
14 | `;
15 |
--------------------------------------------------------------------------------
/tests/__snapshots__/invalidate.spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Overflow.Invalidate render item 1`] = `
4 |
7 | -
10 | Label 0
11 |
12 | -
15 | Label 1
16 |
17 |
18 | `;
19 |
20 | exports[`Overflow.Invalidate render raw 1`] = `
21 |
24 | -
27 | Label 0
28 |
29 | -
32 | Label 1
33 |
34 |
35 | `;
36 |
--------------------------------------------------------------------------------
/tests/__snapshots__/raw.spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Overflow.Raw HOC usage 1`] = `
4 |
7 |
11 | Bamboo
12 |
13 |
14 | `;
15 |
16 | exports[`Overflow.Raw render node directly 1`] = `
17 |
20 | -
24 | Label 0
25 |
26 |
27 | `;
28 |
29 | exports[`Overflow.Raw safe with item directly 1`] = `
30 |
31 | Bamboo
32 |
33 | `;
34 |
--------------------------------------------------------------------------------
/tests/__snapshots__/ssr.spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Overflow.SSR basic 1`] = `
4 |
7 |
11 | Label 0
12 |
13 |
17 | Label 1
18 |
19 |
24 | + 0 ...
25 |
26 |
27 | `;
28 |
--------------------------------------------------------------------------------
/tests/github.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, act } from '@testing-library/react';
3 | import { spyElementPrototypes } from 'rc-util/lib/test/domHook';
4 | import Overflow from '../src';
5 |
6 | import { _rs as onResize } from 'rc-resize-observer/lib/utils/observerUtil';
7 |
8 | interface ItemType {
9 | label: React.ReactNode;
10 | key: React.Key;
11 | }
12 |
13 | function renderItem(item: ItemType) {
14 | return item.label;
15 | }
16 |
17 | function renderRest(items: ItemType[]) {
18 | return `+${items.length}...`;
19 | }
20 |
21 | describe('Overflow.github', () => {
22 | function getData(count: number) {
23 | return new Array(count).fill(undefined).map((_, index) => ({
24 | label: `Label ${index}`,
25 | key: `k-${index}`,
26 | }));
27 | }
28 |
29 | beforeEach(() => {
30 | jest.useFakeTimers();
31 | });
32 |
33 | afterEach(() => {
34 | jest.useRealTimers();
35 | });
36 |
37 | const widths = {
38 | 'rc-overflow': 100,
39 | 'rc-overflow-item': 90,
40 | };
41 |
42 | const propDef = {
43 | get() {
44 | let targetWidth = 0;
45 | Object.keys(widths).forEach(key => {
46 | if (this.className.includes(key)) {
47 | targetWidth = widths[key];
48 | }
49 | });
50 |
51 | return targetWidth;
52 | },
53 | };
54 |
55 | beforeAll(() => {
56 | spyElementPrototypes(HTMLDivElement, {
57 | clientWidth: propDef,
58 | offsetWidth: propDef,
59 | });
60 | });
61 |
62 | async function triggerResize(target: HTMLElement) {
63 | await act(async () => {
64 | onResize([{ target } as any]);
65 | for (let i = 0; i < 10; i += 1) {
66 | await Promise.resolve();
67 | }
68 | });
69 | }
70 |
71 | it('only one', async () => {
72 | const { container } = render(
73 |
74 | data={getData(2)}
75 | itemKey="key"
76 | renderItem={renderItem}
77 | renderRest={renderRest}
78 | maxCount="responsive"
79 | />,
80 | );
81 |
82 | // width & rest resize
83 | await triggerResize(container.querySelector('.rc-overflow'));
84 | await triggerResize(container.querySelector('.rc-overflow-item-rest'));
85 |
86 | act(() => {
87 | jest.runAllTimers();
88 | });
89 |
90 | const items = Array.from(
91 | container.querySelectorAll(
92 | '.rc-overflow-item:not(.rc-overflow-item-rest)',
93 | ),
94 | );
95 |
96 | for (let i = 0; i < items.length; i += 1) {
97 | await triggerResize(items[i]);
98 | }
99 | act(() => {
100 | jest.runAllTimers();
101 | });
102 |
103 | expect(container.querySelector('.rc-overflow-item-rest')).toHaveStyle({
104 | opacity: 1,
105 | });
106 | });
107 | });
108 |
--------------------------------------------------------------------------------
/tests/index.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Overflow from '../src';
3 | import { mount } from './wrapper';
4 |
5 | interface ItemType {
6 | label: React.ReactNode;
7 | key: React.Key;
8 | }
9 |
10 | function renderItem(item: ItemType) {
11 | return item.label;
12 | }
13 |
14 | describe('Overflow.Basic', () => {
15 | function getData(count: number) {
16 | return new Array(count).fill(undefined).map((_, index) => ({
17 | label: `Label ${index}`,
18 | key: `k-${index}`,
19 | }));
20 | }
21 |
22 | it('no data', () => {
23 | const wrapper = mount( />);
24 | expect(wrapper.findItems()).toHaveLength(0);
25 | });
26 |
27 | it('no maxCount', () => {
28 | const wrapper = mount(
29 | data={getData(6)} renderItem={renderItem} />,
30 | );
31 | expect(wrapper.find('ResizeObserver')).toHaveLength(0);
32 | expect(wrapper.findItems()).toHaveLength(6);
33 | expect(wrapper.findRest()).toHaveLength(0);
34 | });
35 |
36 | it('number maxCount', () => {
37 | const wrapper = mount(
38 |
39 | data={getData(6)}
40 | renderItem={renderItem}
41 | maxCount={4}
42 | />,
43 | );
44 | expect(wrapper.find('ResizeObserver')).toHaveLength(0);
45 | expect(wrapper.findItems()).toHaveLength(4);
46 | expect(wrapper.findRest()).toHaveLength(1);
47 | });
48 |
49 | it('without renderItem', () => {
50 | const wrapper = mount(Bamboo Is Light]} />);
51 | expect(wrapper.find('Item').text()).toEqual('Bamboo Is Light');
52 | });
53 |
54 | it('renderItem params have "order"', () => {
55 | const testData = getData(3);
56 | const wrapper = mount(
57 | {
60 | return `${item.label}-${info.index}-test`;
61 | }}
62 | />,
63 | );
64 | const renderedItems = wrapper.find('.rc-overflow-item');
65 | expect(renderedItems).toHaveLength(testData.length);
66 | renderedItems.forEach((node, index) => {
67 | expect(node.text()).toBe(`${testData[index].label}-${index}-test`);
68 | });
69 | });
70 | describe('renderRest', () => {
71 | it('function', () => {
72 | const wrapper = mount(
73 | `Bamboo: ${omittedItems.length}`}
77 | maxCount={3}
78 | />,
79 | );
80 |
81 | expect(wrapper.findRest().text()).toEqual('Bamboo: 3');
82 | });
83 |
84 | it('node', () => {
85 | const wrapper = mount(
86 | Light Is Bamboo}
90 | maxCount={3}
91 | />,
92 | );
93 |
94 | expect(wrapper.findRest().text()).toEqual('Light Is Bamboo');
95 | });
96 | });
97 |
98 | describe('itemKey', () => {
99 | it('string', () => {
100 | const wrapper = mount(
101 | ,
102 | );
103 |
104 | expect(wrapper.find('Item').key()).toEqual('k-0');
105 | });
106 | it('function', () => {
107 | const wrapper = mount(
108 | `bamboo-${item.key}`}
112 | />,
113 | );
114 |
115 | expect(wrapper.find('Item').key()).toEqual('bamboo-k-0');
116 | });
117 | });
118 |
119 | it('customize component', () => {
120 | const wrapper = mount(
121 | `bamboo-${item.key}`}
125 | component="ul"
126 | itemComponent="li"
127 | />,
128 | );
129 |
130 | expect(wrapper.render()).toMatchSnapshot();
131 | });
132 | });
133 |
--------------------------------------------------------------------------------
/tests/invalidate.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Overflow from '../src';
3 | import { mount } from './wrapper';
4 |
5 | interface ItemType {
6 | label: React.ReactNode;
7 | key: React.Key;
8 | }
9 |
10 | describe('Overflow.Invalidate', () => {
11 | function getData(count: number) {
12 | return new Array(count).fill(undefined).map((_, index) => ({
13 | label: `Label ${index}`,
14 | key: `k-${index}`,
15 | }));
16 | }
17 |
18 | it('render item', () => {
19 | const wrapper = mount(
20 |
21 | data={getData(2)}
22 | renderItem={item => {
23 | return item.label;
24 | }}
25 | itemKey={item => `bamboo-${item.key}`}
26 | itemComponent="li"
27 | component="ul"
28 | maxCount={Overflow.INVALIDATE}
29 | />,
30 | );
31 |
32 | expect(wrapper.render()).toMatchSnapshot();
33 | });
34 |
35 | it('render raw', () => {
36 | const wrapper = mount(
37 |
38 | data={getData(2)}
39 | renderRawItem={item => {
40 | return {item.label};
41 | }}
42 | itemKey={item => `bamboo-${item.key}`}
43 | component="ul"
44 | maxCount={Overflow.INVALIDATE}
45 | />,
46 | );
47 |
48 | expect(wrapper.render()).toMatchSnapshot();
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/tests/raw.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Overflow from '../src';
3 | import Item from '../src/Item';
4 | import { mount } from './wrapper';
5 |
6 | interface ItemType {
7 | label: React.ReactNode;
8 | key: React.Key;
9 | }
10 |
11 | describe('Overflow.Raw', () => {
12 | function getData(count: number) {
13 | return new Array(count).fill(undefined).map((_, index) => ({
14 | label: `Label ${index}`,
15 | key: `k-${index}`,
16 | }));
17 | }
18 |
19 | it('render node directly', () => {
20 | const elements = new Set();
21 |
22 | const wrapper = mount(
23 |
24 | data={getData(1)}
25 | renderRawItem={item => {
26 | return (
27 | {
30 | elements.add(ele);
31 | }}
32 | >
33 | {item.label}
34 |
35 | );
36 | }}
37 | itemKey={item => `bamboo-${item.key}`}
38 | component="ul"
39 | />,
40 | );
41 |
42 | const elementList = [...elements];
43 | expect(elementList).toHaveLength(1);
44 | expect(elementList[0] instanceof HTMLLIElement).toBeTruthy();
45 |
46 | expect(wrapper.render()).toMatchSnapshot();
47 | });
48 |
49 | it('safe with item directly', () => {
50 | const wrapper = mount(Bamboo);
51 |
52 | expect(wrapper.render()).toMatchSnapshot();
53 |
54 | expect(wrapper.exists(Item)).toBeFalsy();
55 | });
56 |
57 | it('HOC usage', () => {
58 | interface SharedProps {
59 | visible?: boolean;
60 | children?: React.ReactNode;
61 | }
62 |
63 | const ComponentWrapper = (props: SharedProps) => (
64 |
65 | );
66 |
67 | const UserHOC = ({ visible, ...props }: SharedProps) =>
68 | visible ? : null;
69 |
70 | const wrapper = mount(
71 | Light,
74 |
75 | Bamboo
76 | ,
77 | ]}
78 | renderRawItem={node => node}
79 | itemKey={node => node.key}
80 | />,
81 | );
82 |
83 | expect(wrapper.render()).toMatchSnapshot();
84 | });
85 | });
86 |
--------------------------------------------------------------------------------
/tests/responsive.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { act } from 'react-dom/test-utils';
3 | import Overflow from '../src';
4 | import { mount } from './wrapper';
5 |
6 | interface ItemType {
7 | label: React.ReactNode;
8 | key: React.Key;
9 | }
10 |
11 | function renderItem(item: ItemType) {
12 | return item.label;
13 | }
14 |
15 | describe('Overflow.Responsive', () => {
16 | function getData(count: number) {
17 | return new Array(count).fill(undefined).map((_, index) => ({
18 | label: `Label ${index}`,
19 | key: `k-${index}`,
20 | }));
21 | }
22 |
23 | beforeEach(() => {
24 | jest.useFakeTimers();
25 | });
26 |
27 | afterEach(() => {
28 | jest.useRealTimers();
29 | });
30 |
31 | it('basic', () => {
32 | const wrapper = mount(
33 |
34 | data={getData(6)}
35 | renderItem={renderItem}
36 | maxCount="responsive"
37 | />,
38 | );
39 |
40 | wrapper.initSize(100, 20); // [0][1][2][3][4][+2](5)(6)
41 | expect(wrapper.findItems()).toHaveLength(6);
42 | [true, true, true, true, false, false].forEach((display, i) => {
43 | expect(wrapper.findItems().at(i).props().display).toBe(display);
44 | });
45 | expect(wrapper.findRest()).toHaveLength(1);
46 | expect(wrapper.findRest().text()).toEqual('+ 2 ...');
47 | expect(
48 | wrapper.findItems().find('div').last().prop('aria-hidden'),
49 | ).toBeTruthy();
50 | });
51 |
52 | it('only one', () => {
53 | const wrapper = mount(
54 |
55 | data={getData(1)}
56 | itemKey="key"
57 | renderItem={renderItem}
58 | maxCount="responsive"
59 | />,
60 | );
61 | wrapper.initSize(100, 20);
62 |
63 | expect(wrapper.findItems()).toHaveLength(1);
64 | expect(wrapper.findRest().props().display).toBeFalsy();
65 | });
66 |
67 | it('just fit', () => {
68 | const wrapper = mount(
69 |
70 | data={getData(1)}
71 | itemKey="key"
72 | renderItem={renderItem}
73 | maxCount="responsive"
74 | />,
75 | );
76 | wrapper.initSize(20, 20);
77 |
78 | expect(wrapper.findItems()).toHaveLength(1);
79 | expect(wrapper.findRest().props().display).toBeFalsy();
80 | });
81 |
82 | it('remove to clean up', () => {
83 | const data = getData(6);
84 |
85 | const wrapper = mount(
86 |
87 | data={data}
88 | itemKey="key"
89 | renderItem={renderItem}
90 | maxCount="responsive"
91 | />,
92 | );
93 | wrapper.initSize(100, 20);
94 |
95 | // Remove one (Just fit the container width)
96 | const newData = [...data];
97 | newData.splice(1, 1);
98 | wrapper.setProps({ data: newData });
99 | wrapper.update();
100 |
101 | expect(wrapper.findItems()).toHaveLength(5);
102 | expect(wrapper.findRest().props().display).toBeFalsy();
103 |
104 | // Remove one (More place for container)
105 | const restData = [...newData];
106 | restData.splice(1, 2);
107 | restData.push({
108 | label: 'Additional',
109 | key: 'additional',
110 | });
111 | wrapper.setProps({ data: restData });
112 | wrapper.update();
113 |
114 | expect(wrapper.findItems()).toHaveLength(4);
115 | expect(wrapper.findRest().props().display).toBeFalsy();
116 | });
117 |
118 | it('none to overflow', () => {
119 | const data = getData(5);
120 |
121 | const wrapper = mount(
122 |
123 | data={data}
124 | itemKey="key"
125 | renderItem={renderItem}
126 | maxCount="responsive"
127 | />,
128 | );
129 |
130 | wrapper.initSize(100, 20);
131 | expect(wrapper.findItems()).toHaveLength(5);
132 | expect(wrapper.findRest().props().display).toBeFalsy();
133 |
134 | // Add one
135 | const newData: ItemType[] = [
136 | {
137 | label: 'Additional',
138 | key: 'additional',
139 | },
140 | ...data,
141 | ];
142 | wrapper.setProps({ data: newData });
143 | wrapper.update();
144 |
145 | // Currently resize observer not trigger, rest node is not ready
146 | expect(wrapper.findItems()).toHaveLength(6);
147 | expect(wrapper.findRest().props().display).toBeFalsy();
148 |
149 | // Trigger resize, node ready
150 | wrapper.triggerItemResize(0, 20);
151 | expect(wrapper.findItems()).toHaveLength(6);
152 | expect(wrapper.findRest().props().display).toBeTruthy();
153 | });
154 |
155 | it('unmount no error', () => {
156 | const wrapper = mount(
157 |
158 | data={getData(1)}
159 | itemKey="key"
160 | renderItem={renderItem}
161 | maxCount="responsive"
162 | />,
163 | );
164 |
165 | wrapper.initSize(100, 20);
166 |
167 | wrapper.unmount();
168 |
169 | act(() => {
170 | jest.runAllTimers();
171 | });
172 | });
173 |
174 | describe('suffix', () => {
175 | it('ping the position', () => {
176 | const wrapper = mount(
177 |
178 | data={getData(10)}
179 | itemKey="key"
180 | renderItem={renderItem}
181 | maxCount="responsive"
182 | suffix="Bamboo"
183 | />,
184 | );
185 |
186 | wrapper.initSize(100, 20);
187 |
188 | expect(wrapper.findSuffix().props().style).toEqual(
189 | expect.objectContaining({ position: 'absolute', top: 0, left: 80 }),
190 | );
191 | });
192 |
193 | it('too long to pin', () => {
194 | const wrapper = mount(
195 |
196 | data={getData(1)}
197 | itemKey="key"
198 | renderItem={renderItem}
199 | maxCount="responsive"
200 | suffix="Bamboo"
201 | />,
202 | );
203 |
204 | wrapper.initSize(100, 20);
205 | wrapper.triggerItemResize(0, 90);
206 |
207 | expect(wrapper.findSuffix().props().style.position).toBeFalsy();
208 | });
209 |
210 | it('long to short should keep correct position', () => {
211 | const wrapper = mount(
212 |
213 | data={getData(3)}
214 | itemKey="key"
215 | renderItem={renderItem}
216 | maxCount="responsive"
217 | suffix="Bamboo"
218 | />,
219 | );
220 |
221 | wrapper.initSize(20, 20);
222 | wrapper.setProps({ data: [] });
223 |
224 | expect(wrapper.findRest()).toHaveLength(0);
225 | expect(wrapper.findSuffix().props().style.position).toBeFalsy();
226 | });
227 | });
228 |
229 | it('render rest directly', () => {
230 | const wrapper = mount(
231 |
232 | data={getData(10)}
233 | itemKey="key"
234 | renderItem={renderItem}
235 | maxCount="responsive"
236 | renderRawRest={omitItems => {
237 | return (
238 |
239 | {omitItems.length}
240 |
241 | );
242 | }}
243 | />,
244 | );
245 |
246 | wrapper.initSize(100, 20);
247 |
248 | expect(wrapper.find('span.custom-rest').text()).toEqual('6');
249 | });
250 | });
251 |
--------------------------------------------------------------------------------
/tests/setup.js:
--------------------------------------------------------------------------------
1 | const Enzyme = require('enzyme');
2 | const Adapter = require('enzyme-adapter-react-16');
3 | const { act } = require('react-dom/test-utils');
4 | require('regenerator-runtime/runtime');
5 |
6 | window.requestAnimationFrame = (func) => {
7 | window.setTimeout(func, 16);
8 | };
9 |
10 | Enzyme.configure({ adapter: new Adapter() });
11 |
12 | Object.assign(Enzyme.ReactWrapper.prototype, {
13 | triggerResize(clientWidth) {
14 | const target = this.find('ResizeObserver').first()
15 | target.invoke('onResize')({}, { clientWidth })
16 | act(() => {
17 | jest.runAllTimers();
18 | })
19 | this.update()
20 | },
21 | triggerItemResize(index, offsetWidth) {
22 | const target = this.find('Item').at(index).find('ResizeObserver')
23 | target.invoke('onResize')({ offsetWidth });
24 | act(() => {
25 | jest.runAllTimers();
26 | })
27 | this.update()
28 | },
29 | initSize(width, itemWidth) {
30 | this.triggerResize(width);
31 | this.find('Item').forEach((_, index) => {
32 | this.triggerItemResize(index, itemWidth);
33 | });
34 | },
35 | findItems() {
36 | return this.find('Item').filterWhere(
37 | (item) =>
38 | item.props().className !== 'rc-overflow-item-rest' &&
39 | item.props().className !== 'rc-overflow-item-suffix',
40 | );
41 | },
42 | findRest() {
43 | return this.find('Item.rc-overflow-item-rest');
44 | },
45 | findSuffix() {
46 | return this.find('Item.rc-overflow-item-suffix');
47 | },
48 | });
49 |
--------------------------------------------------------------------------------
/tests/ssr.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from 'enzyme';
3 | import Overflow from '../src';
4 |
5 | interface ItemType {
6 | label: React.ReactNode;
7 | key: React.Key;
8 | }
9 |
10 | function renderItem(item: ItemType) {
11 | return item.label;
12 | }
13 |
14 | describe('Overflow.SSR', () => {
15 | function getData(count: number) {
16 | return new Array(count).fill(undefined).map((_, index) => ({
17 | label: `Label ${index}`,
18 | key: `k-${index}`,
19 | }));
20 | }
21 |
22 | beforeEach(() => {
23 | jest.useFakeTimers();
24 | });
25 |
26 | afterEach(() => {
27 | jest.useRealTimers();
28 | });
29 |
30 | it('basic', () => {
31 | const wrapper = render(
32 |
33 | data={getData(2)}
34 | renderItem={renderItem}
35 | maxCount="responsive"
36 | ssr="full"
37 | />,
38 | );
39 |
40 | expect(wrapper).toMatchSnapshot();
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/tests/wrapper.ts:
--------------------------------------------------------------------------------
1 | import type { ReactWrapper } from 'enzyme';
2 | import { mount as enzymeMount } from 'enzyme';
3 |
4 | export type MountParam = Parameters;
5 |
6 | export interface WrapperType extends ReactWrapper {
7 | triggerResize: (offsetWidth: number) => WrapperType;
8 | triggerItemResize: (index: number, offsetWidth: number) => WrapperType;
9 | initSize: (width: number, itemWidth: number) => WrapperType;
10 | findItems: () => WrapperType;
11 | findRest: () => WrapperType;
12 | findSuffix: () => WrapperType;
13 | }
14 |
15 | export function mount(...args: MountParam) {
16 | return enzymeMount(...args) as WrapperType;
17 | }
18 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "moduleResolution": "node",
5 | "baseUrl": "./",
6 | "jsx": "react",
7 | "declaration": true,
8 | "skipLibCheck": true,
9 | "esModuleInterop": true,
10 | "paths": {
11 | "@/*": ["src/*"],
12 | "@@/*": [".dumi/tmp/*"],
13 | "rc-overflow": ["src/index.tsx"]
14 | }
15 | }
16 | }
--------------------------------------------------------------------------------