diff --git a/.gitattributes b/.gitattributes
index 9f60d4df1..fb7ceecb6 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1 @@
-*.snap filter=lfs diff=lfs merge=lfs -text
+*.snap filter=lfs diff=lfs merge=lfs -text
\ No newline at end of file
diff --git a/.husky/post-checkout b/.husky/post-checkout
new file mode 100755
index 000000000..c37815e2b
--- /dev/null
+++ b/.husky/post-checkout
@@ -0,0 +1,3 @@
+#!/bin/sh
+command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting '.git/hooks/post-checkout'.\n"; exit 2; }
+git lfs post-checkout "$@"
diff --git a/.husky/post-commit b/.husky/post-commit
new file mode 100755
index 000000000..e5230c305
--- /dev/null
+++ b/.husky/post-commit
@@ -0,0 +1,3 @@
+#!/bin/sh
+command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting '.git/hooks/post-commit'.\n"; exit 2; }
+git lfs post-commit "$@"
diff --git a/.husky/post-merge b/.husky/post-merge
new file mode 100755
index 000000000..c99b752a5
--- /dev/null
+++ b/.husky/post-merge
@@ -0,0 +1,3 @@
+#!/bin/sh
+command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting '.git/hooks/post-merge'.\n"; exit 2; }
+git lfs post-merge "$@"
diff --git a/.husky/pre-push b/.husky/pre-push
new file mode 100755
index 000000000..216e91527
--- /dev/null
+++ b/.husky/pre-push
@@ -0,0 +1,3 @@
+#!/bin/sh
+command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting '.git/hooks/pre-push'.\n"; exit 2; }
+git lfs pre-push "$@"
diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html
index 429f9c4a9..95a970841 100644
--- a/.storybook/preview-head.html
+++ b/.storybook/preview-head.html
@@ -1,7 +1,26 @@
-
+
diff --git a/global.d.ts b/global.d.ts
index 0641b5506..aa0afd4d2 100644
--- a/global.d.ts
+++ b/global.d.ts
@@ -3,6 +3,8 @@ declare module '*.scss' {
export default styles;
}
+declare module '@ngard/tiny-isequal';
+
/**
* userLanguage type for IE i18n
*/
diff --git a/package.json b/package.json
index a00986252..832a604c3 100644
--- a/package.json
+++ b/package.json
@@ -49,9 +49,16 @@
"@floating-ui/react-dom": "0.6.0",
"@floating-ui/react-dom-interactions": "0.6.3",
"@mdi/react": "1.5.0",
+ "@ngard/tiny-isequal": "1.1.0",
+ "@types/lodash": "4.14.182",
+ "@types/react-is": "17.0.3",
+ "@types/shallowequal": "1.1.1",
"bodymovin": "4.13.0",
"lottie-web": "5.8.1",
- "react-flip-toolkit": "7.0.13"
+ "react-flip-toolkit": "7.0.13",
+ "react-is": "18.1.0",
+ "resize-observer-polyfill": "1.5.1",
+ "shallowequal": "1.1.0"
},
"peerDependencies": {
"@types/react": "17.0.39",
@@ -95,6 +102,7 @@
"@types/jest": "24.0.23",
"@types/node": "16.11.26",
"@types/react-color": "3.0.6",
+ "@types/react-window": "1.8.2",
"@types/webpack": "5.28.0",
"@typescript-eslint/eslint-plugin": "5.14.0",
"@typescript-eslint/parser": "5.14.0",
@@ -138,6 +146,7 @@
"jest-watch-typeahead": "1.0.0",
"lint-staged": "12.3.6",
"mini-css-extract-plugin": "2.6.0",
+ "mockdate": "3.0.0",
"postcss": "8.4.4",
"postcss-flexbugs-fixes": "5.0.2",
"postcss-loader": "6.2.1",
@@ -149,7 +158,10 @@
"react-dev-utils": "12.0.0",
"react-docgen-typescript": "2.2.2",
"react-refresh": "0.11.0",
+ "react-sortable-hoc": "2.0.0",
"react-test-renderer": "17.0.2",
+ "react-window": "1.8.5",
+ "regenerator-runtime": "0.13.7",
"resolve": "1.20.0",
"resolve-url-loader": "4.0.0",
"sass": "1.47.0",
diff --git a/src/__snapshots__/storybook.test.js.snap b/src/__snapshots__/storybook.test.js.snap
index 51918595c..a54577b15 100644
--- a/src/__snapshots__/storybook.test.js.snap
+++ b/src/__snapshots__/storybook.test.js.snap
@@ -1,11882 +1,3 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Storyshots Accordion List Horizontal 1`] = `
-
-
-
- Header
-
-
-
-
-
-
-
-
- Body 2 text used here. Bottom bars are sticky sections that
- can be used to highlight a few actions that are out of the
- view to be displayed inside the view. For example, if
- there's a very long form with Save and Cancel buttons at the
- bottom, we can use the bottom bar to show those two buttons
- in the view. We are making these bars to be flexible in
- height and also allowing any component to be
-
-
-
-
-
-
-
-
-
- Body 2 text used here. Bottom bars are sticky sections that
- can be used to highlight a few actions that are out of the
- view to be displayed inside the view. For example, if
- there's a very long form with Save and Cancel buttons at the
- bottom, we can use the bottom bar to show those two buttons
- in the view. We are making these bars to be flexible in
- height and also allowing any component to be
-
-
-
-
-
-
-
- Footer
-
-
-
-`;
-
-exports[`Storyshots Accordion List Vertical 1`] = `
-
-
-
- Header
-
-
-
-
-
-
-
-
- Body 2 text used here. Bottom bars are sticky sections that
- can be used to highlight a few actions that are out of the
- view to be displayed inside the view. For example, if
- there's a very long form with Save and Cancel buttons at the
- bottom, we can use the bottom bar to show those two buttons
- in the view. We are making these bars to be flexible in
- height and also allowing any component to be
-
-
-
-
-
-
-
-
-
- Body 2 text used here. Bottom bars are sticky sections that
- can be used to highlight a few actions that are out of the
- view to be displayed inside the view. For example, if
- there's a very long form with Save and Cancel buttons at the
- bottom, we can use the bottom bar to show those two buttons
- in the view. We are making these bars to be flexible in
- height and also allowing any component to be
-
-
-
-
-
-
-
- Footer
-
-
-
-`;
-
-exports[`Storyshots Accordion Single 1`] = `
-
-
-
-
-
- Body 2 text used here. Bottom bars are sticky sections that can be used to highlight a few actions that are out of the view to be displayed inside the view. For example, if there's a very long form with Save and Cancel buttons at the bottom, we can use the bottom bar to show those two buttons in the view. We are making these bars to be flexible in height and also allowing any component to be added inside for now, to understand use cases from the team.
-
-
-
-
-`;
-
-exports[`Storyshots Avatar Avatar Default 1`] = `
-
-
-
-`;
-
-exports[`Storyshots Avatar Avatar Fallback Hashing 1`] = `
-
- HF
-
-`;
-
-exports[`Storyshots Avatar Avatar Fallback Theme 1`] = `
-
- AB
-
-`;
-
-exports[`Storyshots Avatar Avatar Icon 1`] = `
-
-`;
-
-exports[`Storyshots Avatar Avatar Round 1`] = `
-
-
-
-`;
-
-exports[`Storyshots Avatar Avatar Round Icon 1`] = `
-
-`;
-
-exports[`Storyshots Badge Badge Active 1`] = `
-
- 8
-
-`;
-
-exports[`Storyshots Badge Badge Default 1`] = `
-
- 8
-
-`;
-
-exports[`Storyshots Badge Badge Disruptive 1`] = `
-
- 8
-
-`;
-
-exports[`Storyshots Button Counter 1`] = `
-
-
-
-
-
-
-
-
- Primary Button
-
- 8
-
-
-
-
-`;
-
-exports[`Storyshots Button Default 1`] = `
-
-
-
-
-
-
-
-
- Default Button
-
-
-
-`;
-
-exports[`Storyshots Button Neutral 1`] = `
-
-
-
-
-
-
-
-
- Neutral Button
-
-
-
-`;
-
-exports[`Storyshots Button Primary 1`] = `
-
-
-
-
-
-
-
-
- Primary Button
-
-
-
-`;
-
-exports[`Storyshots Button Secondary 1`] = `
-
-
-
-
-
-
-
-
- Secondary Button
-
-
-
-`;
-
-exports[`Storyshots Button Split 1`] = `
-Array [
-
-
- Split Button
-
- ,
-
-
-
-
-
-
-
- ,
-]
-`;
-
-exports[`Storyshots Button Split With Counter 1`] = `
-Array [
-
-
- Split Button
-
- 8
-
-
- ,
-
-
-
-
-
-
-
- ,
-]
-`;
-
-exports[`Storyshots Button Toggle 1`] = `
-
-
-
-
-
-
-
-
- Toggle Button
-
-
-
-`;
-
-exports[`Storyshots Button Toggle With Counter 1`] = `
-
-
-
-
-
-
-
-
- Toggle Button
-
- 8
-
-
-
-
-`;
-
-exports[`Storyshots Button Two State Button 1`] = `
-
-
-
-
-
-
-
-
-
- Two State Button
-
-
- 8
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Check Box Check Box 1`] = `
-
-
-
-
-
- Label
-
-
-
-`;
-
-exports[`Storyshots Check Box Check Box Group 1`] = `
-
-`;
-
-exports[`Storyshots Config Provider Theming 1`] = `
-
-
- Selected Theme:
-
- blue
-
-
-
-
-
- Predefined
-
-
-
- red
-
-
- orange
-
-
- yellow
-
-
- green
-
-
- bluegreen
-
-
- blue
-
-
- violet
-
-
- grey
-
-
-
-
-
- Custom
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Primary Button
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Primary Button
-
-
-
-
-
-
-
- Secondary Button
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Secondary Button
-
-
-
-
-
-
-
- Default Button
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Default Button
-
-
-
-
-
-
-
-
- Tab 1
-
-
-
-
-
- Tab 2
-
-
-
-
- Tab 3
-
-
-
-
- Tab 4
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Tab 1
-
-
-
-
-
- Tab 2
-
-
-
-
- Tab 3
-
-
-
-
- Tab 4
-
-
-
-
-
-
-
-
- Tab 1
-
-
-
-
-
- Tab 2
-
-
-
-
- Tab 3
-
-
-
-
- Tab 4
-
-
-
-
-
-
-
-
-
-
-
-
- 3
- /
- 5
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Dialog Medium 1`] = `
-
-
- Open dialog
-
-
-`;
-
-exports[`Storyshots Dialog Small 1`] = `
-
-
- Open dialog
-
-
-`;
-
-exports[`Storyshots Dropdown Dropdown Button 1`] = `
-
-
-
-
-
-
-
-
-
- Click button start
-
-
-
-
-`;
-
-exports[`Storyshots Dropdown Dropdown Div 1`] = `
-
-
-
- HTMLDivElement
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Empty Custom Image 1`] = `
-
-
-
- Short Message Here
-
-
- More detail on how might the user be able to get around this
-
-
-`;
-
-exports[`Storyshots Empty Empty Messages 1`] = `
-
-
-
- Short Message Here
-
-
- More detail on how might the user be able to get around this
-
-
-`;
-
-exports[`Storyshots Empty Error State 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Short Message Here
-
-
- More detail on how might the user be able to get around this
-
-
-`;
-
-exports[`Storyshots Empty No Data 1`] = `
-
-
-
- Short Message Here
-
-
- More detail on how might the user be able to get around this
-
-
-`;
-
-exports[`Storyshots Empty No Search Results 1`] = `
-
-
-
- Short Message Here
-
-
- More detail on how might the user be able to get around this
-
-
-`;
-
-exports[`Storyshots Empty Tasks Complete 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Short Message Here
-
-
- More detail on how might the user be able to get around this
-
-
-`;
-
-exports[`Storyshots Icon Basic 1`] = `
-
-
-
- My icon title.
-
-
- My icon description.
-
-
-
-
-`;
-
-exports[`Storyshots Icon Icomoon 1`] = `
-
-
-
-
-
-`;
-
-exports[`Storyshots Icon Icomoon Multicolor 1`] = `
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Info Bar Disruptive 1`] = `
-
-
-
-
-
-
-
- Body2 is used inside here.
-
-
-
- Action
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Info Bar Neutral 1`] = `
-
-
-
-
-
-
-
- Body2 is used inside here.
-
-
-
- Action
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Info Bar Positive 1`] = `
-
-
-
-
-
-
-
- Body2 is used inside here.
-
-
-
- Action
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Info Bar Warning 1`] = `
-
-
-
-
-
-
-
- Body2 is used inside here.
-
-
-
- Action
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Input Search Box 1`] = `
-
-`;
-
-exports[`Storyshots Input Text Area 1`] = `
-
-`;
-
-exports[`Storyshots Input Text Input 1`] = `
-
-`;
-
-exports[`Storyshots Label Labels 1`] = `
-
-
- This is a label
-
-
-
-
-
-`;
-
-exports[`Storyshots Link Default 1`] = `
-
- Twitter
-
-`;
-
-exports[`Storyshots Link Default Disabled 1`] = `
-
-
-
-
-
-
- User Profile
-
-`;
-
-exports[`Storyshots Link Primary 1`] = `
-
-
-
-
-
-
- User Profile
-
-`;
-
-exports[`Storyshots List Horizontal 1`] = `
-
-
-
- Header
-
-
-
-
-
-
- User 1
-
-
- Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.
-
-
-
-
-
-
- User 2
-
-
- Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.
-
-
-
-
-
-
- User 3
-
-
- Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.
-
-
-
-
-
-
- User 4
-
-
- Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.
-
-
-
-
-
-
- User 5
-
-
- Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.
-
-
-
-
-
-
- Footer
-
-
-
-`;
-
-exports[`Storyshots List Vertical 1`] = `
-
-
-
- Header
-
-
-
-
-
-
- User 1
-
-
- Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.
-
-
-
-
-
-
- User 2
-
-
- Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.
-
-
-
-
-
-
- User 3
-
-
- Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.
-
-
-
-
-
-
- User 4
-
-
- Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.
-
-
-
-
-
-
- User 5
-
-
- Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.
-
-
-
-
-
-
- Footer
-
-
-
-`;
-
-exports[`Storyshots Match Score Default 1`] = `
-
-
-
-
-
-
-
- 3
- /
- 5
-
-
-`;
-
-exports[`Storyshots Match Score Without Label 1`] = `
-
-`;
-
-exports[`Storyshots Menu Menus 1`] = `
-
-
-
- Menu dropdown
-
-
-
-`;
-
-exports[`Storyshots Modal Fullscreen 1`] = `
-
-
- Open modal
-
-
-`;
-
-exports[`Storyshots Modal Large 1`] = `
-
-
- Open modal
-
-
-`;
-
-exports[`Storyshots Modal Medium 1`] = `
-
-
- Open modal
-
-
-`;
-
-exports[`Storyshots Modal Scrollable 1`] = `
-
-
- Open modal
-
-
-`;
-
-exports[`Storyshots Modal Small 1`] = `
-
-
- Open modal
-
-
-`;
-
-exports[`Storyshots Modal X Large 1`] = `
-
-
- Open modal
-
-
-`;
-
-exports[`Storyshots Navbar Navbar Div 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Pagination All Combined 1`] = `
-
-
-
-
-
-
-
-
-
-
-
- page / 100
-
-
-
-
-
-
- 400 Total
-
-
-
-
-
-
-
-
-
-
-
-
- 1
-
-
-
-
-
-
- 0
-
-
-
-
-
-
- 4
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Pagination Basic Few 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1
-
-
-
-
-
-
- 0
-
-
-
-
-
-
- 5
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Pagination Basic Many 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1
-
-
-
-
-
-
- 0
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 100
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Pagination Change Page Size 1`] = `
-
-
-
-
-
-
-
-
-
-
-
- page / 1000
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1
-
-
-
-
-
-
- 0
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Pagination Dots 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1
-
-
-
-
-
-
- 0
-
-
-
-
-
-
- 5
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Pagination Jump To 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1
-
-
-
-
-
-
- 0
-
-
-
-
-
-
- 10
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Pagination Total Item Count 1`] = `
-
-
- 1000 Total
-
-
-
-
-
-
-
-
-
-
-
-
- 1
-
-
-
-
-
-
- 0
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 100
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Panel Bottom 1`] = `
-
-
- Open panel
-
-
-`;
-
-exports[`Storyshots Panel Large 1`] = `
-
-
- Open panel
-
-
-`;
-
-exports[`Storyshots Panel Left 1`] = `
-
-
- Open panel
-
-
-`;
-
-exports[`Storyshots Panel Medium 1`] = `
-
-
- Open panel
-
-
-`;
-
-exports[`Storyshots Panel Small 1`] = `
-
-
- Open panel
-
-
-`;
-
-exports[`Storyshots Panel Stacked 1`] = `
-
-
- Open panel
-
-
-`;
-
-exports[`Storyshots Panel Top 1`] = `
-
-
- Open panel
-
-
-`;
-
-exports[`Storyshots Pill Closable 1`] = `
-
-
-
- red
-
-
-
-
-
-
-
-
-
-
-
- orange
-
-
-
-
-
-
-
-
-
-
-
- yellow
-
-
-
-
-
-
-
-
-
-
-
- green
-
-
-
-
-
-
-
-
-
-
-
- bluegreen
-
-
-
-
-
-
-
-
-
-
-
- blue
-
-
-
-
-
-
-
-
-
-
-
- violet
-
-
-
-
-
-
-
-
-
-
-
- grey
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Pill Default 1`] = `
-
-
-
- red
-
-
-
-
- orange
-
-
-
-
- yellow
-
-
-
-
- green
-
-
-
-
- bluegreen
-
-
-
-
- blue
-
-
-
-
- violet
-
-
-
-
- grey
-
-
-
-`;
-
-exports[`Storyshots Pill With Button 1`] = `
-
-
-
- red
-
-
-
-
-
-
-
-
-
- 2
-
-
-
-
-
-
- orange
-
-
-
-
-
-
-
-
-
- 2
-
-
-
-
-
-
- yellow
-
-
-
-
-
-
-
-
-
- 2
-
-
-
-
-
-
- green
-
-
-
-
-
-
-
-
-
- 2
-
-
-
-
-
-
- bluegreen
-
-
-
-
-
-
-
-
-
- 2
-
-
-
-
-
-
- blue
-
-
-
-
-
-
-
-
-
- 2
-
-
-
-
-
-
- violet
-
-
-
-
-
-
-
-
-
- 2
-
-
-
-
-
-
- grey
-
-
-
-
-
-
-
-
-
- 2
-
-
-
-
-
-`;
-
-exports[`Storyshots Pill With Icon 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
- bluegreen
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Portal Default 1`] = `
`;
-
-exports[`Storyshots Radio Button Radio Button 1`] = `
-
-
-
-
-
- Label
-
-
-
-`;
-
-exports[`Storyshots Radio Button Radio Group 1`] = `
-
-`;
-
-exports[`Storyshots Select Basic 1`] = `
-
-`;
-
-exports[`Storyshots Select Disabled 1`] = `
-
-`;
-
-exports[`Storyshots Select Dynamic 1`] = `
-
-`;
-
-exports[`Storyshots Select Filterable 1`] = `
-
-`;
-
-exports[`Storyshots Select Multiple 1`] = `
-
-`;
-
-exports[`Storyshots Select Multiple With No Filter 1`] = `
-
-`;
-
-exports[`Storyshots Select Options Disabled 1`] = `
-
-`;
-
-exports[`Storyshots Select With Clear 1`] = `
-
-`;
-
-exports[`Storyshots Select With Default Value 1`] = `
-
-`;
-
-exports[`Storyshots Slider Range Slider 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 110
-
-
- 150
-
-
- 0
-
-
- 200
-
-
-`;
-
-exports[`Storyshots Slider Standard Slider 1`] = `
-
-
-
- 2
-
-
- 1
-
-
- 5
-
-
-`;
-
-exports[`Storyshots Snack Bar Closable 1`] = `
-
-
- Serve closable snack
-
-
-`;
-
-exports[`Storyshots Snack Bar Default 1`] = `
-
-
- Serve snack
-
-
-`;
-
-exports[`Storyshots Snack Bar With Action 1`] = `
-
-
- Serve snack with action
-
-
-`;
-
-exports[`Storyshots Spinner Default 1`] = `
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Stack Horizontal 1`] = `
-
-`;
-
-exports[`Storyshots Stack Responsive 1`] = `
-
-`;
-
-exports[`Storyshots Stack Sample Nav List 1`] = `
-
-
-
- Title
-
-
-
- subheading
-
-
- subheading
-
-
-
-
-
- Title
-
-
-
- subheading
-
-
- subheading
-
-
- subheading
-
-
- subheading
-
-
-
-
-
- Title
-
-
-
- subheading
-
-
- subheading
-
-
-
-
-
- Title
-
-
-
- subheading
-
-
- subheading
-
-
- subheading
-
-
-
-
-
- Title
-
-
-
- subheading
-
-
- subheading
-
-
- subheading
-
-
- subheading
-
-
-
-
-
- Title
-
-
-
- subheading
-
-
- subheading
-
-
- subheading
-
-
-
-
-`;
-
-exports[`Storyshots Stack Vertical 1`] = `
-
-`;
-
-exports[`Storyshots Tabs Default 1`] = `
-
-
-
-
- Tab 1
-
-
-
-
-
- Tab 2
-
-
-
-
- Tab 3
-
-
-
-
- Tab 4
-
-
-
-
-`;
-
-exports[`Storyshots Tabs Icon 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Tabs Icon Label 1`] = `
-
-
-
-
-
-
-
-
-
- Tab 1
-
-
-
-
-
-
-
-
-
-
- Tab 2
-
-
-
-
-
-
-
-
-
- Tab 3
-
-
-
-
-
-
-
-
-
- Tab 4
-
-
-
-
-`;
-
-exports[`Storyshots Tabs Pill Default 1`] = `
-
-
-
-
- Tab 1
-
-
-
-
-
- Tab 2
-
-
-
-
- Tab 3
-
-
-
-
- Tab 4
-
-
-
-
-`;
-
-exports[`Storyshots Tabs Pill Icon 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Storyshots Tabs Pill Icon Label 1`] = `
-
-
-
-
-
-
-
-
-
- Tab 1
-
-
-
-
-
-
-
-
-
-
- Tab 2
-
-
-
-
-
-
-
-
-
- Tab 3
-
-
-
-
-
-
-
-
-
- Tab 4
-
-
-
-
-`;
-
-exports[`Storyshots Tabs Pill With Badge 1`] = `
-
-
-
-
- Tab 1
-
-
-
- 1
-
-
-
-
- Tab 2
-
-
- 2
-
-
-
-
- Tab 3
-
-
- 3
-
-
-
-
- Tab 4
-
-
- 4
-
-
-
-
-`;
-
-exports[`Storyshots Tabs Scrollable 1`] = `
-
-
-
-
- Tab 1
-
-
-
-
-
- Tab 2
-
-
-
-
- Tab 3
-
-
-
-
- Tab 4
-
-
-
-
- Tab 5
-
-
-
-
- Tab 6
-
-
-
-
- Tab 7
-
-
-
-
- Tab 8
-
-
-
-
- Tab 9
-
-
-
-
- Tab 10
-
-
-
-
-`;
-
-exports[`Storyshots Tabs Small 1`] = `
-
-
-
-
- Tab 1
-
-
-
-
-
- Tab 2
-
-
-
-
- Tab 3
-
-
-
-
- Tab 4
-
-
-
-
-`;
-
-exports[`Storyshots Tabs With Badge 1`] = `
-
-
-
-
- Tab 1
-
-
-
- 1
-
-
-
-
- Tab 2
-
-
- 2
-
-
-
-
- Tab 3
-
-
- 3
-
-
-
-
- Tab 4
-
-
- 4
-
-
-
-
-`;
-
-exports[`Storyshots Tooltip Tooltips 1`] = `
-
-
-
- Show Tooltip
-
-
-
-`;
+version https://git-lfs.github.com/spec/v1
+oid sha256:71d229409bc29def35df60c1686a2f911e200934c035fc5dce872e341cd70b26
+size 1811243
diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx
index 211476843..361d0693c 100644
--- a/src/components/Accordion/Accordion.tsx
+++ b/src/components/Accordion/Accordion.tsx
@@ -1,10 +1,9 @@
import React, { FC, Ref, useCallback, useState } from 'react';
-import { mergeClasses, uniqueId } from '../../shared/utilities';
+import { eventKeys, mergeClasses, uniqueId } from '../../shared/utilities';
import { AccordionProps, AccordionSummaryProps, AccordionBodyProps } from './';
import { Icon, IconName } from '../Icon';
-import { eventKeys } from '../../shared/eventKeys';
import styles from './accordion.module.scss';
diff --git a/src/components/Empty/svg/DefaultEmptyImg.tsx b/src/components/Empty/svg/DefaultEmptyImg.tsx
new file mode 100644
index 000000000..ec2f6d66e
--- /dev/null
+++ b/src/components/Empty/svg/DefaultEmptyImg.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+
+export const DefaultEmptyImg = (): JSX.Element => {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/Icon/mdi.ts b/src/components/Icon/mdi.ts
index 975771906..dbc1d6056 100644
--- a/src/components/Icon/mdi.ts
+++ b/src/components/Icon/mdi.ts
@@ -216,6 +216,10 @@ export enum IconName {
mdiFlaskEmpty = 'M6,22A3,3 0 0,1 3,19C3,18.4 3.18,17.84 3.5,17.37L9,7.81V6A1,1 0 0,1 8,5V4A2,2 0 0,1 10,2H14A2,2 0 0,1 16,4V5A1,1 0 0,1 15,6V7.81L20.5,17.37C20.82,17.84 21,18.4 21,19A3,3 0 0,1 18,22H6Z',
mdiFlaskEmptyOutline = 'M5,19A1,1 0 0,0 6,20H18A1,1 0 0,0 19,19C19,18.79 18.93,18.59 18.82,18.43L13,8.35V4H11V8.35L5.18,18.43C5.07,18.59 5,18.79 5,19M6,22A3,3 0 0,1 3,19C3,18.4 3.18,17.84 3.5,17.37L9,7.81V6A1,1 0 0,1 8,5V4A2,2 0 0,1 10,2H14A2,2 0 0,1 16,4V5A1,1 0 0,1 15,6V7.81L20.5,17.37C20.82,17.84 21,18.4 21,19A3,3 0 0,1 18,22H6Z',
mdiFlaskOutline = 'M5,19A1,1 0 0,0 6,20H18A1,1 0 0,0 19,19C19,18.79 18.93,18.59 18.82,18.43L13,8.35V4H11V8.35L5.18,18.43C5.07,18.59 5,18.79 5,19M6,22A3,3 0 0,1 3,19C3,18.4 3.18,17.84 3.5,17.37L9,7.81V6A1,1 0 0,1 8,5V4A2,2 0 0,1 10,2H14A2,2 0 0,1 16,4V5A1,1 0 0,1 15,6V7.81L20.5,17.37C20.82,17.84 21,18.4 21,19A3,3 0 0,1 18,22H6M13,16L14.34,14.66L16.27,18H7.73L10.39,13.39L13,16M12.5,12A0.5,0.5 0 0,1 13,12.5A0.5,0.5 0 0,1 12.5,13A0.5,0.5 0 0,1 12,12.5A0.5,0.5 0 0,1 12.5,12Z',
+ mdiFolder = 'M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z',
+ mdiFolderOpen = 'M19,20H4C2.89,20 2,19.1 2,18V6C2,4.89 2.89,4 4,4H10L12,6H19A2,2 0 0,1 21,8H21L4,8V18L6.14,10H23.21L20.93,18.5C20.7,19.37 19.92,20 19,20Z',
+ mdiFolderOpenOutline = 'M6.1,10L4,18V8H21A2,2 0 0,0 19,6H12L10,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H19C19.9,20 20.7,19.4 20.9,18.5L23.2,10H6.1M19,18H6L7.6,12H20.6L19,18Z',
+ mdiFolderOutline = 'M20,18H4V8H20M20,6H12L10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6Z',
mdiFormatBold = 'M13.5,15.5H10V12.5H13.5A1.5,1.5 0 0,1 15,14A1.5,1.5 0 0,1 13.5,15.5M10,6.5H13A1.5,1.5 0 0,1 14.5,8A1.5,1.5 0 0,1 13,9.5H10M15.6,10.79C16.57,10.11 17.25,9 17.25,8C17.25,5.74 15.5,4 13.25,4H7V18H14.04C16.14,18 17.75,16.3 17.75,14.21C17.75,12.69 16.89,11.39 15.6,10.79Z',
mdiFormatColorText = 'M9.62,12L12,5.67L14.37,12M11,3L5.5,17H7.75L8.87,14H15.12L16.25,17H18.5L13,3H11Z',
mdiFormatItalic = 'M10,4V7H12.21L8.79,15H6V18H14V15H11.79L15.21,7H18V4H10Z',
@@ -231,6 +235,7 @@ export enum IconName {
mdiGenderNonBinary = 'M13 3H11V5.27L9.04 4.13L8.04 5.87L10 7L8.04 8.13L9.04 9.87L11 8.73V12.1C8.72 12.56 7 14.58 7 17C7 19.76 9.24 22 12 22S17 19.76 17 17C17 14.58 15.28 12.56 13 12.1V8.73L14.96 9.87L15.96 8.13L14 7L15.96 5.87L14.96 4.13L13 5.27V3M12 20C10.35 20 9 18.65 9 17S10.35 14 12 14 15 15.35 15 17 13.65 20 12 20Z',
mdiGenderTransgender = 'M19.58,3H15V1H23V9H21V4.41L16.17,9.24C16.69,10.03 17,11 17,12C17,14.42 15.28,16.44 13,16.9V19H15V21H13V23H11V21H9V19H11V16.9C8.72,16.44 7,14.42 7,12C7,11 7.3,10.04 7.82,9.26L6.64,8.07L5.24,9.46L3.83,8.04L5.23,6.65L3,4.42V8H1V1H8V3H4.41L6.64,5.24L8.08,3.81L9.5,5.23L8.06,6.66L9.23,7.84C10,7.31 11,7 12,7C13,7 13.96,7.3 14.75,7.83L19.58,3M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9Z',
mdiGoogle = 'M21.35,11.1H12.18V13.83H18.69C18.36,17.64 15.19,19.27 12.19,19.27C8.36,19.27 5,16.25 5,12C5,7.9 8.2,4.73 12.2,4.73C15.29,4.73 17.1,6.7 17.1,6.7L19,4.72C19,4.72 16.56,2 12.1,2C6.42,2 2.03,6.8 2.03,12C2.03,17.05 6.16,22 12.25,22C17.6,22 21.5,18.33 21.5,12.91C21.5,11.76 21.35,11.1 21.35,11.1V11.1Z',
+ mdiHandBackRight = 'M13 24C9.74 24 6.81 22 5.6 19L2.57 11.37C2.26 10.58 3 9.79 3.81 10.05L4.6 10.31C5.16 10.5 5.62 10.92 5.84 11.47L7.25 15H8V3.25C8 2.56 8.56 2 9.25 2S10.5 2.56 10.5 3.25V12H11.5V1.25C11.5 .56 12.06 0 12.75 0S14 .56 14 1.25V12H15V2.75C15 2.06 15.56 1.5 16.25 1.5C16.94 1.5 17.5 2.06 17.5 2.75V12H18.5V5.75C18.5 5.06 19.06 4.5 19.75 4.5S21 5.06 21 5.75V16C21 20.42 17.42 24 13 24Z',
mdiHelp = 'M10,19H13V22H10V19M12,2C17.35,2.22 19.68,7.62 16.5,11.67C15.67,12.67 14.33,13.33 13.67,14.17C13,15 13,16 13,17H10C10,15.33 10,13.92 10.67,12.92C11.33,11.92 12.67,11.33 13.5,10.67C15.92,8.43 15.32,5.26 12,5A3,3 0 0,0 9,8H6A6,6 0 0,1 12,2Z',
mdiHelpCircle = 'M15.07,11.25L14.17,12.17C13.45,12.89 13,13.5 13,15H11V14.5C11,13.39 11.45,12.39 12.17,11.67L13.41,10.41C13.78,10.05 14,9.55 14,9C14,7.89 13.1,7 12,7A2,2 0 0,0 10,9H8A4,4 0 0,1 12,5A4,4 0 0,1 16,9C16,9.88 15.64,10.67 15.07,11.25M13,19H11V17H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z',
mdiHelpCircleOutline = 'M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z',
@@ -308,6 +313,10 @@ export enum IconName {
mdiPlayCircle = 'M10,16.5V7.5L16,12M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z',
mdiPlayCircleOutline = 'M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M10,16.5L16,12L10,7.5V16.5Z',
mdiPlus = 'M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z',
+ mdiPlusBox = 'M17,13H13V17H11V13H7V11H11V7H13V11H17M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z',
+ mdiPlusBoxMultiple = 'M19,11H15V15H13V11H9V9H13V5H15V9H19M20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16V4A2,2 0 0,0 20,2M4,6H2V20A2,2 0 0,0 4,22H18V20H4V6Z',
+ mdiPlusBoxMultipleOutline = 'M18 11H15V14H13V11H10V9H13V6H15V9H18M20 4V16H8V4H20M20 2H8C6.9 2 6 2.9 6 4V16C6 17.11 6.9 18 8 18H20C21.11 18 22 17.11 22 16V4C22 2.9 21.11 2 20 2M4 6H2V20C2 21.11 2.9 22 4 22H18V20H4V6Z',
+ mdiPlusBoxOutline = 'M19,19V5H5V19H19M19,3A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5A2,2 0 0,1 3,19V5C3,3.89 3.9,3 5,3H19M11,7H13V11H17V13H13V17H11V13H7V11H11V7Z',
mdiPlusCircle = 'M17,13H13V17H11V13H7V11H11V7H13V11H17M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z',
mdiPlusCircleOutline = 'M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M13,7H11V11H7V13H11V17H13V13H17V11H13V7Z',
mdiPlusThick = 'M20 14H14V20H10V14H4V10H10V4H14V10H20V14Z',
diff --git a/src/components/Motion/CSSMotion.tsx b/src/components/Motion/CSSMotion.tsx
new file mode 100644
index 000000000..f35aebbd1
--- /dev/null
+++ b/src/components/Motion/CSSMotion.tsx
@@ -0,0 +1,149 @@
+import React from 'react';
+import { useRef } from 'react';
+import {
+ DomWrapper,
+ findDOMNode,
+ fillRef,
+ mergeClasses,
+} from '../../shared/utilities';
+import { getTransitionName } from './util/motion';
+import type { CSSMotionProps } from './CSSMotion.types';
+import { STATUS_NONE, STEP_PREPARE, STEP_START } from './CSSMotion.types';
+import { useStatus } from './hooks/useStatus';
+import { isActive } from './hooks/useStepQueue';
+
+export function genCSSMotion(): React.ForwardRefExoticComponent<
+ CSSMotionProps & { ref?: React.Ref }
+> {
+ const CSSMotion = React.forwardRef((props, ref) => {
+ const {
+ // Default config
+ visible = true,
+ removeOnLeave = true,
+
+ forceRender,
+ children,
+ motionName,
+ leavedClassName,
+ eventProps,
+ } = props;
+
+ // Ref to the react node, it may be a HTMLElement
+ const nodeRef = useRef();
+ // Ref to the dom wrapper in case ref can not pass to HTMLElement
+ const wrapperNodeRef = useRef();
+
+ function getDomElement() {
+ try {
+ // Here we're avoiding call for findDOMNode since it's deprecated
+ // in strict mode. We're calling it only when node ref is not
+ // an instance of DOM HTMLElement. Otherwise use
+ // findDOMNode as a final resort
+ return nodeRef.current instanceof HTMLElement
+ ? nodeRef.current
+ : findDOMNode(wrapperNodeRef.current);
+ } catch (e) {
+ // Only happen when `motionDeadline` trigger but element removed.
+ return null;
+ }
+ }
+
+ const [status, statusStep, statusStyle, mergedVisible] = useStatus(
+ visible,
+ getDomElement,
+ props
+ );
+
+ // Record whether content has rendered
+ // Will return null for un-rendered even when `removeOnLeave={false}`
+ const renderedRef = React.useRef(mergedVisible);
+ if (mergedVisible) {
+ renderedRef.current = true;
+ }
+
+ // ====================== Refs ======================
+ const setNodeRef = React.useCallback(
+ (node: any) => {
+ nodeRef.current = node;
+ fillRef(ref, node);
+ },
+ [ref]
+ );
+
+ // ===================== Render =====================
+ let motionChildren: React.ReactNode;
+ const mergedProps = { ...eventProps, visible };
+
+ if (!children) {
+ // No children
+ motionChildren = null;
+ } else if (status === STATUS_NONE) {
+ // Stable children
+ if (mergedVisible) {
+ motionChildren = children({ ...mergedProps }, setNodeRef);
+ } else if (!removeOnLeave && renderedRef.current) {
+ motionChildren = children(
+ { ...mergedProps, className: leavedClassName },
+ setNodeRef
+ );
+ } else if (forceRender) {
+ motionChildren = children(
+ { ...mergedProps, style: { display: 'none' } },
+ setNodeRef
+ );
+ } else {
+ motionChildren = null;
+ }
+ } else {
+ // In motion
+ let statusSuffix: string;
+ if (statusStep === STEP_PREPARE) {
+ statusSuffix = 'prepare';
+ } else if (isActive(statusStep)) {
+ statusSuffix = 'active';
+ } else if (statusStep === STEP_START) {
+ statusSuffix = 'start';
+ }
+
+ motionChildren = children(
+ {
+ ...mergedProps,
+ className: mergeClasses([
+ getTransitionName(motionName, status),
+ {
+ [getTransitionName(
+ motionName,
+ `${status}-${statusSuffix}`
+ )]: statusSuffix,
+ },
+ {
+ [motionName as string]:
+ typeof motionName === 'string',
+ },
+ ]),
+ style: statusStyle,
+ },
+ setNodeRef
+ );
+ }
+
+ // Auto inject ref if child node not have `ref` props
+ if (React.isValidElement(motionChildren)) {
+ const { ref: originNodeRef } = motionChildren as any;
+
+ if (!originNodeRef) {
+ motionChildren = React.cloneElement(motionChildren, {
+ ref: setNodeRef,
+ });
+ }
+ }
+
+ return {motionChildren} ;
+ });
+
+ CSSMotion.displayName = 'CSSMotion';
+
+ return CSSMotion;
+}
+
+export default genCSSMotion();
diff --git a/src/components/Motion/CSSMotion.types.ts b/src/components/Motion/CSSMotion.types.ts
new file mode 100644
index 000000000..a6dd032a0
--- /dev/null
+++ b/src/components/Motion/CSSMotion.types.ts
@@ -0,0 +1,157 @@
+import type { KeyObject } from './util/diff';
+
+export const MOTION_PROP_NAMES: string[] = [
+ 'eventProps',
+ 'visible',
+ 'children',
+ 'motionName',
+ 'motionAppear',
+ 'motionEnter',
+ 'motionLeave',
+ 'motionLeaveImmediately',
+ 'motionDeadline',
+ 'removeOnLeave',
+ 'leavedClassName',
+ 'onAppearStart',
+ 'onAppearActive',
+ 'onAppearEnd',
+ 'onEnterStart',
+ 'onEnterActive',
+ 'onEnterEnd',
+ 'onLeaveStart',
+ 'onLeaveActive',
+ 'onLeaveEnd',
+];
+
+export const STATUS_NONE = 'none' as const;
+export const STATUS_APPEAR = 'appear' as const;
+export const STATUS_ENTER = 'enter' as const;
+export const STATUS_LEAVE = 'leave' as const;
+
+export type MotionStatus =
+ | typeof STATUS_NONE
+ | typeof STATUS_APPEAR
+ | typeof STATUS_ENTER
+ | typeof STATUS_LEAVE;
+
+export const STEP_NONE = 'none' as const;
+export const STEP_PREPARE = 'prepare' as const;
+export const STEP_START = 'start' as const;
+export const STEP_ACTIVE = 'active' as const;
+export const STEP_ACTIVATED = 'end' as const;
+
+export type StepStatus =
+ | typeof STEP_NONE
+ | typeof STEP_PREPARE
+ | typeof STEP_START
+ | typeof STEP_ACTIVE
+ | typeof STEP_ACTIVATED;
+
+export type MotionEvent = (TransitionEvent | AnimationEvent) & {
+ deadline?: boolean;
+};
+
+export type MotionPrepareEventHandler = (
+ element: HTMLElement
+) => Promise | void;
+
+export type MotionEventHandler = (
+ element: HTMLElement,
+ event: MotionEvent
+) => React.CSSProperties | void;
+
+export type MotionEndEventHandler = (
+ element: HTMLElement,
+ event: MotionEvent
+) => boolean | void;
+
+export type MotionName =
+ | string
+ | {
+ appear?: string;
+ enter?: string;
+ leave?: string;
+ appearActive?: string;
+ enterActive?: string;
+ leaveActive?: string;
+ };
+
+export interface CSSMotionListProps
+ extends Omit,
+ Omit, 'children'> {
+ keys: (React.Key | { key: React.Key; [name: string]: any })[];
+ component?: string | React.ComponentType | false;
+
+ /** This will always trigger after final visible changed. Even if no motion configured. */
+ onVisibleChanged?: (visible: boolean, info: { key: React.Key }) => void;
+ /** All motion leaves in the screen */
+ onAllRemoved?: () => void;
+}
+
+export interface CSSMotionListState {
+ keyEntities: KeyObject[];
+}
+
+export interface CSSMotionProps {
+ motionName?: MotionName;
+ visible?: boolean;
+ motionAppear?: boolean;
+ motionEnter?: boolean;
+ motionLeave?: boolean;
+ motionLeaveImmediately?: boolean;
+ motionDeadline?: number;
+ /**
+ * Create element in view even the element is invisible.
+ * Will patch `display: none` style on it.
+ */
+ forceRender?: boolean;
+ /**
+ * Remove element when motion end. This will not work when `forceRender` is set.
+ */
+ removeOnLeave?: boolean;
+ leavedClassName?: string;
+ /** @private Used by CSSMotionList. */
+ eventProps?: object;
+
+ // Prepare groups
+ onAppearPrepare?: MotionPrepareEventHandler;
+ onEnterPrepare?: MotionPrepareEventHandler;
+ onLeavePrepare?: MotionPrepareEventHandler;
+
+ // Normal motion groups
+ onAppearStart?: MotionEventHandler;
+ onEnterStart?: MotionEventHandler;
+ onLeaveStart?: MotionEventHandler;
+
+ onAppearActive?: MotionEventHandler;
+ onEnterActive?: MotionEventHandler;
+ onLeaveActive?: MotionEventHandler;
+
+ onAppearEnd?: MotionEndEventHandler;
+ onEnterEnd?: MotionEndEventHandler;
+ onLeaveEnd?: MotionEndEventHandler;
+
+ // Special
+ /** This will always trigger after final visible changed. Even if no motion configured. */
+ onVisibleChanged?: (visible: boolean) => void;
+
+ internalRef?: React.Ref;
+
+ children?: (
+ props: {
+ visible?: boolean;
+ classNames?: string;
+ style?: React.CSSProperties;
+ [key: string]: any;
+ },
+ ref: (node: any) => void
+ ) => React.ReactElement;
+}
+
+export interface CSSMotionState {
+ status?: MotionStatus;
+ statusActive?: boolean;
+ newStatus?: boolean;
+ statusStyle?: React.CSSProperties;
+ prevProps?: CSSMotionProps;
+}
diff --git a/src/components/Motion/CSSMotionList.tsx b/src/components/Motion/CSSMotionList.tsx
new file mode 100644
index 000000000..38cbcbcd4
--- /dev/null
+++ b/src/components/Motion/CSSMotionList.tsx
@@ -0,0 +1,144 @@
+import React from 'react';
+import OriginCSSMotion from './CSSMotion';
+import type { CSSMotionProps } from './CSSMotion.types';
+import {
+ MOTION_PROP_NAMES,
+ CSSMotionListProps,
+ CSSMotionListState,
+} from './CSSMotion.types';
+import {
+ STATUS_ADD,
+ STATUS_KEEP,
+ STATUS_REMOVE,
+ STATUS_REMOVED,
+ diffKeys,
+ parseKeys,
+} from './util/diff';
+
+/**
+ * Generate a CSSMotionList component with config
+ * @param CSSMotion CSSMotion component
+ */
+export function genCSSMotionList(
+ CSSMotion = OriginCSSMotion
+): React.ComponentClass {
+ class CSSMotionList extends React.Component<
+ CSSMotionListProps,
+ CSSMotionListState
+ > {
+ static defaultProps = {
+ component: 'div',
+ };
+
+ state: CSSMotionListState = {
+ keyEntities: [],
+ };
+
+ static getDerivedStateFromProps(
+ { keys }: CSSMotionListProps,
+ { keyEntities }: CSSMotionListState
+ ) {
+ const parsedKeyObjects = parseKeys({ keys });
+ const mixedKeyEntities = diffKeys(keyEntities, parsedKeyObjects);
+
+ return {
+ keyEntities: mixedKeyEntities.filter((entity) => {
+ const prevEntity = keyEntities.find(
+ ({ key }) => entity.key === key
+ );
+
+ // Remove if already mark as removed
+ if (
+ prevEntity &&
+ prevEntity.status === STATUS_REMOVED &&
+ entity.status === STATUS_REMOVE
+ ) {
+ return false;
+ }
+ return true;
+ }),
+ };
+ }
+
+ removeKey = (removeKey: React.Key) => {
+ const { keyEntities } = this.state;
+ const nextKeyEntities = keyEntities.map((entity) => {
+ if (entity.key !== removeKey) return entity;
+ return {
+ ...entity,
+ status: STATUS_REMOVED,
+ };
+ });
+
+ this.setState({
+ keyEntities: nextKeyEntities,
+ });
+
+ return nextKeyEntities.filter(
+ ({ status }) => status !== STATUS_REMOVED
+ ).length;
+ };
+
+ render() {
+ const { keyEntities } = this.state;
+ const {
+ component,
+ children,
+ onVisibleChanged,
+ onAllRemoved,
+ ...rest
+ } = this.props;
+
+ const Component = component || React.Fragment;
+ const motionProps: CSSMotionProps = {};
+
+ MOTION_PROP_NAMES.forEach((prop) => {
+ (motionProps as any)[prop] = (rest as any)[prop];
+ delete (rest as any)[prop];
+ });
+
+ delete rest.keys;
+
+ return (
+
+ {keyEntities.map(({ status, ...eventProps }) => {
+ const visible =
+ status === STATUS_ADD || status === STATUS_KEEP;
+ return (
+ {
+ onVisibleChanged?.(changedVisible, {
+ key: eventProps.key,
+ });
+
+ if (!changedVisible) {
+ const restKeysCount = this.removeKey(
+ eventProps.key
+ );
+
+ if (
+ restKeysCount === 0 &&
+ onAllRemoved
+ ) {
+ onAllRemoved();
+ }
+ }
+ }}
+ >
+ {children}
+
+ );
+ })}
+
+ );
+ }
+ }
+
+ return CSSMotionList;
+}
+
+export default genCSSMotionList();
diff --git a/src/components/Motion/hooks/useDomMotionEvents.ts b/src/components/Motion/hooks/useDomMotionEvents.ts
new file mode 100644
index 000000000..bde4f0d2f
--- /dev/null
+++ b/src/components/Motion/hooks/useDomMotionEvents.ts
@@ -0,0 +1,52 @@
+import * as React from 'react';
+import { useRef } from 'react';
+import { animationEndName, transitionEndName } from '../util/motion';
+import type { MotionEvent } from '../CSSMotion.types';
+
+export const useDomMotionEvents = (
+ callback: (event: MotionEvent) => void
+): [(element: HTMLElement) => void, (element: HTMLElement) => void] => {
+ const cacheElementRef = useRef();
+
+ // Cache callback
+ const callbackRef = useRef(callback);
+ callbackRef.current = callback;
+
+ // Internal motion event handler
+ const onInternalMotionEnd = React.useCallback((event: MotionEvent) => {
+ callbackRef.current(event);
+ }, []);
+
+ // Remove events
+ function removeMotionEvents(element: HTMLElement) {
+ if (element) {
+ element.removeEventListener(transitionEndName, onInternalMotionEnd);
+ element.removeEventListener(animationEndName, onInternalMotionEnd);
+ }
+ }
+
+ // Patch events
+ function patchMotionEvents(element: HTMLElement) {
+ if (cacheElementRef.current && cacheElementRef.current !== element) {
+ removeMotionEvents(cacheElementRef.current);
+ }
+
+ if (element && element !== cacheElementRef.current) {
+ element.addEventListener(transitionEndName, onInternalMotionEnd);
+ element.addEventListener(animationEndName, onInternalMotionEnd);
+
+ // Save as cache in case dom removed trigger by `motionDeadline`
+ cacheElementRef.current = element;
+ }
+ }
+
+ // Clean up when removed
+ React.useEffect(
+ () => () => {
+ removeMotionEvents(cacheElementRef.current);
+ },
+ []
+ );
+
+ return [patchMotionEvents, removeMotionEvents];
+};
diff --git a/src/components/Motion/hooks/useNextFrame.ts b/src/components/Motion/hooks/useNextFrame.ts
new file mode 100644
index 000000000..1b508aa02
--- /dev/null
+++ b/src/components/Motion/hooks/useNextFrame.ts
@@ -0,0 +1,41 @@
+import React from 'react';
+import { requestAnimationFrameWrapper } from '../../../shared/utilities';
+
+export const useNextFrame = (): [
+ (callback: (info: { isCanceled: () => boolean }) => void) => void,
+ () => void
+] => {
+ const nextFrameRef = React.useRef(null);
+
+ function cancelNextFrame() {
+ requestAnimationFrameWrapper.cancel(nextFrameRef.current);
+ }
+
+ function nextFrame(
+ callback: (info: { isCanceled: () => boolean }) => void,
+ delay = 2
+ ) {
+ cancelNextFrame();
+
+ const nextFrameId = requestAnimationFrameWrapper(() => {
+ if (delay <= 1) {
+ callback({
+ isCanceled: () => nextFrameId !== nextFrameRef.current,
+ });
+ } else {
+ nextFrame(callback, delay - 1);
+ }
+ });
+
+ nextFrameRef.current = nextFrameId;
+ }
+
+ React.useEffect(
+ () => () => {
+ cancelNextFrame();
+ },
+ []
+ );
+
+ return [nextFrame, cancelNextFrame];
+};
diff --git a/src/components/Motion/hooks/useStatus.ts b/src/components/Motion/hooks/useStatus.ts
new file mode 100644
index 000000000..6b30fc23c
--- /dev/null
+++ b/src/components/Motion/hooks/useStatus.ts
@@ -0,0 +1,240 @@
+import React, { useRef, useEffect, useLayoutEffect } from 'react';
+import { useSafeState } from '../../../hooks/useState';
+import {
+ STATUS_APPEAR,
+ STATUS_NONE,
+ STATUS_LEAVE,
+ STATUS_ENTER,
+ STEP_PREPARE,
+ STEP_START,
+ STEP_ACTIVE,
+} from '../CSSMotion.types';
+import type {
+ CSSMotionProps,
+ MotionStatus,
+ MotionEventHandler,
+ MotionEvent,
+ MotionPrepareEventHandler,
+ StepStatus,
+} from '../CSSMotion.types';
+import { DoStep, SkipStep, isActive, useStepQueue } from './useStepQueue';
+import { useDomMotionEvents } from './useDomMotionEvents';
+
+export const useStatus = (
+ visible: boolean,
+ getElement: () => HTMLElement,
+ {
+ motionEnter = true,
+ motionAppear = true,
+ motionLeave = true,
+ motionDeadline,
+ motionLeaveImmediately,
+ onAppearPrepare,
+ onEnterPrepare,
+ onLeavePrepare,
+ onAppearStart,
+ onEnterStart,
+ onLeaveStart,
+ onAppearActive,
+ onEnterActive,
+ onLeaveActive,
+ onAppearEnd,
+ onEnterEnd,
+ onLeaveEnd,
+ onVisibleChanged,
+ }: CSSMotionProps
+): [MotionStatus, StepStatus, React.CSSProperties, boolean] => {
+ // Used for outer render usage to avoid `visible: false & status: none` to render nothing
+ const [asyncVisible, setAsyncVisible] = useSafeState();
+ const [status, setStatus] = useSafeState(STATUS_NONE);
+ const [style, setStyle] = useSafeState(
+ null
+ );
+
+ const mountedRef = useRef(false);
+ const deadlineRef = useRef(null);
+
+ // =========================== Dom Node ===========================
+ function getDomElement() {
+ return getElement();
+ }
+
+ // ========================== Motion End ==========================
+ const activeRef = useRef(false);
+
+ function onInternalMotionEnd(event: MotionEvent) {
+ const element = getDomElement();
+ if (event && !event.deadline && event.target !== element) {
+ // event exists
+ // not initiated by deadline
+ // transitionEnd not fired by inner elements
+ return;
+ }
+
+ const currentActive = activeRef.current;
+
+ let canEnd: boolean | void;
+ if (status === STATUS_APPEAR && currentActive) {
+ canEnd = onAppearEnd?.(element, event);
+ } else if (status === STATUS_ENTER && currentActive) {
+ canEnd = onEnterEnd?.(element, event);
+ } else if (status === STATUS_LEAVE && currentActive) {
+ canEnd = onLeaveEnd?.(element, event);
+ }
+
+ // Only update status when `canEnd` and not destroyed
+ if (status !== STATUS_NONE && currentActive && canEnd !== false) {
+ setStatus(STATUS_NONE, true);
+ setStyle(null, true);
+ }
+ }
+
+ const [patchMotionEvents] = useDomMotionEvents(onInternalMotionEnd);
+
+ // ============================= Step =============================
+ const eventHandlers = React.useMemo<{
+ [STEP_PREPARE]?: MotionPrepareEventHandler;
+ [STEP_START]?: MotionEventHandler;
+ [STEP_ACTIVE]?: MotionEventHandler;
+ }>(() => {
+ switch (status) {
+ case STATUS_APPEAR:
+ return {
+ [STEP_PREPARE]: onAppearPrepare,
+ [STEP_START]: onAppearStart,
+ [STEP_ACTIVE]: onAppearActive,
+ };
+
+ case STATUS_ENTER:
+ return {
+ [STEP_PREPARE]: onEnterPrepare,
+ [STEP_START]: onEnterStart,
+ [STEP_ACTIVE]: onEnterActive,
+ };
+
+ case STATUS_LEAVE:
+ return {
+ [STEP_PREPARE]: onLeavePrepare,
+ [STEP_START]: onLeaveStart,
+ [STEP_ACTIVE]: onLeaveActive,
+ };
+
+ default:
+ return {};
+ }
+ }, [status]);
+
+ const [startStep, step] = useStepQueue(status, (newStep) => {
+ // Only prepare step can be skip
+ if (newStep === STEP_PREPARE) {
+ const onPrepare = eventHandlers[STEP_PREPARE];
+ if (!onPrepare) {
+ return SkipStep;
+ }
+
+ return onPrepare(getDomElement());
+ }
+
+ // Rest step is sync update
+ if (step in eventHandlers) {
+ setStyle(
+ (eventHandlers as any)[step]?.(getDomElement(), null) || null
+ );
+ }
+
+ if (step === STEP_ACTIVE) {
+ // Patch events when motion needed
+ patchMotionEvents(getDomElement());
+
+ if (motionDeadline > 0) {
+ clearTimeout(deadlineRef.current);
+ deadlineRef.current = setTimeout(() => {
+ onInternalMotionEnd({
+ deadline: true,
+ } as MotionEvent);
+ }, motionDeadline);
+ }
+ }
+
+ return DoStep;
+ });
+
+ const active = isActive(step);
+ activeRef.current = active;
+
+ // ============================ Status ============================
+ // Update with new status
+ useLayoutEffect(() => {
+ setAsyncVisible(visible);
+
+ const isMounted = mountedRef.current;
+ mountedRef.current = true;
+
+ let nextStatus: MotionStatus;
+
+ // Appear
+ if (!isMounted && visible && motionAppear) {
+ nextStatus = STATUS_APPEAR;
+ }
+
+ // Enter
+ if (isMounted && visible && motionEnter) {
+ nextStatus = STATUS_ENTER;
+ }
+
+ // Leave
+ if (
+ (isMounted && !visible && motionLeave) ||
+ (!isMounted && motionLeaveImmediately && !visible && motionLeave)
+ ) {
+ nextStatus = STATUS_LEAVE;
+ }
+
+ // Update to next status
+ if (nextStatus) {
+ setStatus(nextStatus);
+ startStep();
+ }
+ }, [visible]);
+
+ // ============================ Effect ============================
+ // Reset when motion changed
+ useEffect(() => {
+ if (
+ // Cancel appear
+ (status === STATUS_APPEAR && !motionAppear) ||
+ // Cancel enter
+ (status === STATUS_ENTER && !motionEnter) ||
+ // Cancel leave
+ (status === STATUS_LEAVE && !motionLeave)
+ ) {
+ setStatus(STATUS_NONE);
+ }
+ }, [motionAppear, motionEnter, motionLeave]);
+
+ useEffect(
+ () => () => {
+ mountedRef.current = false;
+ clearTimeout(deadlineRef.current);
+ },
+ []
+ );
+
+ // Trigger `onVisibleChanged`
+ useEffect(() => {
+ if (asyncVisible !== undefined && status === STATUS_NONE) {
+ onVisibleChanged?.(asyncVisible);
+ }
+ }, [asyncVisible, status]);
+
+ // ============================ Styles ============================
+ let mergedStyle = style;
+ if (eventHandlers[STEP_PREPARE] && step === STEP_START) {
+ mergedStyle = {
+ transition: 'none',
+ ...mergedStyle,
+ };
+ }
+
+ return [status, step, mergedStyle, asyncVisible ?? visible];
+};
diff --git a/src/components/Motion/hooks/useStepQueue.ts b/src/components/Motion/hooks/useStepQueue.ts
new file mode 100644
index 000000000..9a215ca25
--- /dev/null
+++ b/src/components/Motion/hooks/useStepQueue.ts
@@ -0,0 +1,82 @@
+import React, { useLayoutEffect } from 'react';
+import { useSafeState } from '../../../hooks/useState';
+import type { StepStatus, MotionStatus } from '../CSSMotion.types';
+import {
+ STEP_PREPARE,
+ STEP_ACTIVE,
+ STEP_START,
+ STEP_ACTIVATED,
+ STEP_NONE,
+} from '../CSSMotion.types';
+import { useNextFrame } from './useNextFrame';
+
+const STEP_QUEUE: StepStatus[] = [
+ STEP_PREPARE,
+ STEP_START,
+ STEP_ACTIVE,
+ STEP_ACTIVATED,
+];
+
+/** Skip current step */
+export const SkipStep = false as const;
+/** Current step should be update in */
+export const DoStep = true as const;
+
+export function isActive(step: StepStatus) {
+ return step === STEP_ACTIVE || step === STEP_ACTIVATED;
+}
+
+export const useStepQueue = (
+ status: MotionStatus,
+ callback: (
+ step: StepStatus
+ ) => Promise | void | typeof SkipStep | typeof DoStep
+): [() => void, StepStatus] => {
+ const [step, setStep] = useSafeState(STEP_NONE);
+
+ const [nextFrame, cancelNextFrame] = useNextFrame();
+
+ function startQueue() {
+ setStep(STEP_PREPARE, true);
+ }
+
+ useLayoutEffect(() => {
+ if (step !== STEP_NONE && step !== STEP_ACTIVATED) {
+ const index = STEP_QUEUE.indexOf(step);
+ const nextStep = STEP_QUEUE[index + 1];
+
+ const result = callback(step);
+
+ if (result === SkipStep) {
+ // Skip when no needed
+ setStep(nextStep, true);
+ } else {
+ // Do as frame for step update
+ nextFrame((info) => {
+ function doNext() {
+ // Skip since current queue is ood
+ if (info.isCanceled()) return;
+
+ setStep(nextStep, true);
+ }
+
+ if (result === true) {
+ doNext();
+ } else {
+ // Only promise should be async
+ Promise.resolve(result).then(doNext);
+ }
+ });
+ }
+ }
+ }, [status, step]);
+
+ React.useEffect(
+ () => () => {
+ cancelNextFrame();
+ },
+ []
+ );
+
+ return [startQueue, step];
+};
diff --git a/src/components/Motion/index.tsx b/src/components/Motion/index.tsx
new file mode 100644
index 000000000..ab4f264d4
--- /dev/null
+++ b/src/components/Motion/index.tsx
@@ -0,0 +1,18 @@
+import CSSMotion from './CSSMotion';
+import CSSMotionList from './CSSMotionList';
+import {
+ CSSMotionProps,
+ CSSMotionListProps,
+ MotionEventHandler,
+ MotionEndEventHandler,
+} from './CSSMotion.types';
+
+export {
+ CSSMotionProps,
+ CSSMotionList,
+ CSSMotionListProps,
+ MotionEventHandler,
+ MotionEndEventHandler,
+};
+
+export default CSSMotion;
diff --git a/src/components/Motion/tests/CSSMotion.test.tsx b/src/components/Motion/tests/CSSMotion.test.tsx
new file mode 100644
index 000000000..f7c2e383e
--- /dev/null
+++ b/src/components/Motion/tests/CSSMotion.test.tsx
@@ -0,0 +1,219 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mergeClasses } from '../../../shared/utilities';
+import { render, fireEvent } from '@testing-library/react';
+import type { CSSMotionProps } from '../CSSMotion.types';
+import RefCSSMotion, { genCSSMotion } from '../CSSMotion';
+import ReactDOM from 'react-dom';
+
+describe('CSSMotion', () => {
+ const CSSMotion = genCSSMotion();
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ });
+
+ describe('animation', () => {
+ const actionList = [
+ {
+ name: 'appear',
+ props: { motionAppear: true },
+ visibleQueue: [true],
+ },
+ {
+ name: 'enter',
+ props: { motionEnter: true },
+ visibleQueue: [false, true],
+ },
+ {
+ name: 'leave',
+ props: { motionLeave: true },
+ visibleQueue: [true, false],
+ },
+ ];
+ });
+
+ it('forwardRef', () => {
+ const domRef = React.createRef();
+ render(
+
+ {({ style, className }, ref) => (
+
+ )}
+
+ );
+
+ expect(domRef.current instanceof HTMLElement).toBeTruthy();
+ });
+
+ it("onMotionEnd shouldn't be fired by inner element", () => {
+ const onLeaveEnd = jest.fn();
+
+ const genMotion = (props?: CSSMotionProps) => (
+
+ {(_, ref) => (
+
+ )}
+
+ );
+ const { container, rerender } = render(genMotion());
+
+ function resetLeave() {
+ rerender(genMotion({ visible: true }));
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ rerender(genMotion({ visible: false }));
+ act(() => {
+ jest.runAllTimers();
+ });
+ }
+
+ // Outer
+ resetLeave();
+ fireEvent.transitionEnd(container.querySelector('.outer-block'));
+ expect(onLeaveEnd).toHaveBeenCalledTimes(1);
+
+ // Outer
+ resetLeave();
+ fireEvent.transitionEnd(container.querySelector('.outer-block'));
+ expect(onLeaveEnd).toHaveBeenCalledTimes(2);
+
+ // Inner
+ resetLeave();
+ fireEvent.transitionEnd(container.querySelector('.inner-block'));
+ expect(onLeaveEnd).toHaveBeenCalledTimes(2);
+ });
+
+ it('switch dom should work', () => {
+ const onLeaveEnd = jest.fn();
+
+ const genMotion = (Component: any, visible: boolean) => (
+
+ {({ style, classNames }) => (
+
+ )}
+
+ );
+
+ const { rerender } = render(genMotion('div', true));
+
+ // Active
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ // Hide
+ rerender(genMotion('p', false));
+
+ // Active
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ // Deadline
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ expect(onLeaveEnd).toHaveBeenCalled();
+ });
+
+ describe('strict mode', () => {
+ beforeEach(() => {
+ jest.spyOn(ReactDOM, 'findDOMNode');
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('calls findDOMNode when no refs are passed', () => {
+ const Div = () =>
;
+ render(
+
+ {() =>
}
+
+ );
+
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ expect(ReactDOM.findDOMNode).toHaveBeenCalled();
+ });
+
+ it('does not call findDOMNode when ref is passed internally', () => {
+ render(
+
+ {(props, ref) =>
}
+
+ );
+
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ expect(ReactDOM.findDOMNode).not.toHaveBeenCalled();
+ });
+
+ it('calls findDOMNode when refs are forwarded but not assigned', () => {
+ const domRef = React.createRef();
+ const Div = () =>
;
+
+ render(
+
+ {() =>
}
+
+ );
+
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ expect(ReactDOM.findDOMNode).toHaveBeenCalled();
+ });
+
+ it('does not call findDOMNode when refs are forwarded and assigned', () => {
+ const domRef = React.createRef();
+
+ render(
+
+ {(props, ref) =>
}
+
+ );
+
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ expect(ReactDOM.findDOMNode).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/components/Motion/tests/CSSMotionList.test.tsx b/src/components/Motion/tests/CSSMotionList.test.tsx
new file mode 100644
index 000000000..b77732ec9
--- /dev/null
+++ b/src/components/Motion/tests/CSSMotionList.test.tsx
@@ -0,0 +1,176 @@
+import React from 'react';
+import { mergeClasses } from '../../../shared/utilities';
+import { act } from 'react-dom/test-utils';
+import { render, fireEvent } from '@testing-library/react';
+import { genCSSMotionList } from '../CSSMotionList';
+import type { CSSMotionListProps } from '../CSSMotion.types';
+import { genCSSMotion } from '../CSSMotion';
+
+describe('CSSMotionList', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ describe('diff should work', () => {
+ function testMotion(
+ CSSMotionList: React.ComponentType,
+ injectLeave?: (wrapper: HTMLElement) => void
+ ) {
+ let leaveCalled = 0;
+ function onLeaveEnd() {
+ leaveCalled += 1;
+ }
+
+ const Demo = ({ keys }: { keys: string[] }) => (
+
+ {({ key, style, classNames }) => (
+
+ {key}
+
+ )}
+
+ );
+
+ const { container, rerender } = render( );
+
+ function checkKeys(targetKeys: React.Key[]) {
+ const nodeList = Array.from(
+ container.querySelectorAll('.motion-box')
+ );
+ const keys = nodeList.map((node) => node.textContent);
+ expect(keys).toEqual(targetKeys);
+ }
+
+ checkKeys(['a', 'b']);
+
+ // Change to ['c', 'd']
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ rerender( );
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ // Inject leave event
+ if (injectLeave) {
+ act(() => {
+ injectLeave(container);
+ });
+ }
+
+ act(() => {
+ jest.runAllTimers();
+ });
+ checkKeys(['c', 'd']);
+
+ if (injectLeave) {
+ expect(leaveCalled).toEqual(2);
+ }
+ }
+
+ it('motion', () => {
+ const CSSMotion = genCSSMotion();
+ const CSSMotionList = genCSSMotionList(CSSMotion);
+ testMotion(CSSMotionList, (container) => {
+ const nodeList = Array.from(
+ container.querySelectorAll('.motion-box')
+ );
+ nodeList.slice(0, 2).forEach((node) => {
+ fireEvent.transitionEnd(node);
+ });
+ });
+ });
+ });
+
+ it('onVisibleChanged true', () => {
+ const onVisibleChanged = jest.fn();
+ const onAllRemoved = jest.fn();
+ const CSSMotionList = genCSSMotionList();
+
+ const Demo = ({
+ keys,
+ visible,
+ }: {
+ keys: string[];
+ visible: boolean;
+ }) => (
+
+ {({ key, style, classNames }) => (
+
+ {key}
+
+ )}
+
+ );
+
+ render( );
+
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ expect(onVisibleChanged).toHaveBeenCalledWith(true, { key: 'a' });
+ });
+
+ it('onVisibleChanged false', () => {
+ const onVisibleChanged = jest.fn();
+ const onAllRemoved = jest.fn();
+ const CSSMotionList = genCSSMotionList();
+
+ const Demo = ({
+ keys,
+ visible,
+ }: {
+ keys: string[];
+ visible: boolean;
+ }) => (
+
+ {({ key, style, classNames }) => (
+
+ {key}
+
+ )}
+
+ );
+
+ render( );
+
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ expect(onVisibleChanged).toHaveBeenCalledWith(false, { key: 'a' });
+ });
+});
diff --git a/src/components/Motion/tests/util.test.tsx b/src/components/Motion/tests/util.test.tsx
new file mode 100644
index 000000000..5f6820165
--- /dev/null
+++ b/src/components/Motion/tests/util.test.tsx
@@ -0,0 +1,44 @@
+import { getTransitionName } from '../util/motion';
+import { diffKeys } from '../util/diff';
+
+describe('Util', () => {
+ it('getTransitionName', () => {
+ // Null
+ expect(getTransitionName(null, null)).toBeFalsy();
+
+ // Object
+ expect(
+ getTransitionName(
+ {
+ appearActive: 'light',
+ },
+ 'appear-active'
+ )
+ ).toEqual('light');
+
+ expect(
+ getTransitionName(
+ {
+ appearActive: 'light',
+ },
+ 'appear'
+ )
+ ).toBeFalsy();
+
+ // string
+ expect(getTransitionName('light', 'enter-active')).toEqual(
+ 'light-enter-active'
+ );
+ });
+
+ it('diffKeys', () => {
+ // Deduplicate key when move:
+ // [1 - add, 2 - keep, 1 - remove] -> [1 - keep, 2 - keep]
+ expect(
+ diffKeys([{ key: 1 }, { key: 2 }], [{ key: 2 }, { key: 1 }])
+ ).toEqual([
+ { key: '2', status: 'keep' },
+ { key: '1', status: 'keep' },
+ ]);
+ });
+});
diff --git a/src/components/Motion/util/diff.ts b/src/components/Motion/util/diff.ts
new file mode 100644
index 000000000..98e9bed08
--- /dev/null
+++ b/src/components/Motion/util/diff.ts
@@ -0,0 +1,115 @@
+export const STATUS_ADD = 'add' as const;
+export const STATUS_KEEP = 'keep' as const;
+export const STATUS_REMOVE = 'remove' as const;
+export const STATUS_REMOVED = 'removed' as const;
+export type DiffStatus =
+ | typeof STATUS_ADD
+ | typeof STATUS_KEEP
+ | typeof STATUS_REMOVE
+ | typeof STATUS_REMOVED;
+
+export interface KeyObject {
+ key: React.Key;
+ status?: DiffStatus;
+}
+
+export function wrapKeyToObject(key: React.Key) {
+ let keyObj: KeyObject;
+ if (key && typeof key === 'object' && 'key' in key) {
+ keyObj = key;
+ } else {
+ keyObj = { key };
+ }
+ return {
+ ...keyObj,
+ key: String(keyObj.key),
+ };
+}
+
+export function parseKeys({ keys = [] }: { keys?: any[] } = {}) {
+ return keys.map(wrapKeyToObject);
+}
+
+export function diffKeys(
+ prevKeys: KeyObject[] = [],
+ currentKeys: KeyObject[] = []
+) {
+ let list: KeyObject[] = [];
+ let currentIndex = 0;
+ const currentLen = currentKeys.length;
+
+ const prevKeyObjects = parseKeys({ keys: prevKeys });
+ const currentKeyObjects = parseKeys({ keys: currentKeys });
+
+ // Check prev keys to insert or keep
+ prevKeyObjects.forEach((keyObj) => {
+ let hit = false;
+
+ for (let i = currentIndex; i < currentLen; i += 1) {
+ const currentKeyObj = currentKeyObjects[i];
+ if (currentKeyObj.key === keyObj.key) {
+ // New added keys should add before current key
+ if (currentIndex < i) {
+ list = list.concat(
+ currentKeyObjects
+ .slice(currentIndex, i)
+ .map((obj) => ({ ...obj, status: STATUS_ADD }))
+ );
+ currentIndex = i;
+ }
+ list.push({
+ ...currentKeyObj,
+ status: STATUS_KEEP,
+ });
+ currentIndex += 1;
+
+ hit = true;
+ break;
+ }
+ }
+
+ // If not hit, it means key is removed
+ if (!hit) {
+ list.push({
+ ...keyObj,
+ status: STATUS_REMOVE,
+ });
+ }
+ });
+
+ // Add rest to the list
+ if (currentIndex < currentLen) {
+ list = list.concat(
+ currentKeyObjects
+ .slice(currentIndex)
+ .map((obj) => ({ ...obj, status: STATUS_ADD }))
+ );
+ }
+
+ /**
+ * Merge same key when it remove and add again:
+ * [1 - add, 2 - keep, 1 - remove] -> [1 - keep, 2 - keep]
+ */
+ const keys = {};
+ list.forEach(({ key }) => {
+ (keys as any)[key] = ((keys as any)[key] || 0) + 1;
+ });
+ const duplicatedKeys = Object.keys(keys).filter(
+ (key) => (keys as any)[key] > 1
+ );
+ duplicatedKeys.forEach((matchKey) => {
+ // Remove `STATUS_REMOVE` node.
+ list = list.filter(
+ ({ key, status }) => key !== matchKey || status !== STATUS_REMOVE
+ );
+
+ // Update `STATUS_ADD` to `STATUS_KEEP`
+ list.forEach((node) => {
+ if (node.key === matchKey) {
+ node.status = STATUS_KEEP;
+ }
+ });
+ });
+
+ return list;
+}
diff --git a/src/components/Motion/util/easings.test.tsx b/src/components/Motion/util/easings.test.tsx
new file mode 100644
index 000000000..f028ffa93
--- /dev/null
+++ b/src/components/Motion/util/easings.test.tsx
@@ -0,0 +1,12 @@
+import { easeInOutCubic } from './easings';
+
+describe('Test easings', () => {
+ it('easeInOutCubic return value', () => {
+ const nums = [];
+ for (let index = 0; index < 5; index++) {
+ nums.push(easeInOutCubic(index, 1, 5, 4));
+ }
+
+ expect(nums).toEqual([1, 1.25, 3, 4.75, 5]);
+ });
+});
diff --git a/src/components/Motion/util/easings.ts b/src/components/Motion/util/easings.ts
new file mode 100644
index 000000000..3c6cb1044
--- /dev/null
+++ b/src/components/Motion/util/easings.ts
@@ -0,0 +1,13 @@
+export const easeInOutCubic = (
+ t: number,
+ b: number,
+ c: number,
+ d: number
+): number => {
+ const cc = c - b;
+ t /= d / 2;
+ if (t < 1) {
+ return (cc / 2) * t * t * t + b;
+ }
+ return (cc / 2) * ((t -= 2) * t * t + 2) + b;
+};
diff --git a/src/components/Motion/util/motion.ts b/src/components/Motion/util/motion.ts
new file mode 100644
index 000000000..d03d353ce
--- /dev/null
+++ b/src/components/Motion/util/motion.ts
@@ -0,0 +1,98 @@
+import { canUseDom } from '../../../shared/utilities';
+import { MotionName } from '../CSSMotion.types';
+
+// ================= Transition =================
+// Event wrapper. Copy from react source code
+function makePrefixMap(styleProp: string, eventName: string) {
+ const prefixes: Record = {};
+
+ prefixes[styleProp.toLowerCase()] = eventName.toLowerCase();
+ prefixes[`Webkit${styleProp}`] = `webkit${eventName}`;
+ prefixes[`Moz${styleProp}`] = `moz${eventName}`;
+ prefixes[`ms${styleProp}`] = `MS${eventName}`;
+ prefixes[`O${styleProp}`] = `o${eventName.toLowerCase()}`;
+
+ return prefixes;
+}
+
+export function getVendorPrefixes(domSupport: boolean, win: object) {
+ const prefixes: {
+ animationend: Record;
+ transitionend: Record;
+ } = {
+ animationend: makePrefixMap('Animation', 'AnimationEnd'),
+ transitionend: makePrefixMap('Transition', 'TransitionEnd'),
+ };
+
+ if (domSupport) {
+ if (!('AnimationEvent' in win)) {
+ delete prefixes.animationend.animation;
+ }
+
+ if (!('TransitionEvent' in win)) {
+ delete prefixes.transitionend.transition;
+ }
+ }
+
+ return prefixes;
+}
+
+const vendorPrefixes = getVendorPrefixes(
+ canUseDom(),
+ typeof window !== 'undefined' ? window : {}
+);
+
+let style = {};
+
+if (canUseDom()) {
+ ({ style } = document.createElement('div'));
+}
+
+const prefixedEventNames = {};
+
+export function getVendorPrefixedEventName(eventName: string) {
+ if ((prefixedEventNames as any)[eventName]) {
+ return (prefixedEventNames as any)[eventName];
+ }
+
+ const prefixMap = (vendorPrefixes as any)[eventName];
+
+ if (prefixMap) {
+ const stylePropList = Object.keys(prefixMap);
+ const len = stylePropList.length;
+ for (let i = 0; i < len; i += 1) {
+ const styleProp = stylePropList[i];
+ if (
+ Object.prototype.hasOwnProperty.call(prefixMap, styleProp) &&
+ styleProp in style
+ ) {
+ (prefixedEventNames as any)[eventName] = prefixMap[styleProp];
+ return (prefixedEventNames as any)[eventName];
+ }
+ }
+ }
+
+ return '';
+}
+
+const internalAnimationEndName = getVendorPrefixedEventName('animationend');
+const internalTransitionEndName = getVendorPrefixedEventName('transitionend');
+
+export const animationEndName = internalAnimationEndName || 'animationend';
+export const transitionEndName = internalTransitionEndName || 'transitionend';
+
+export function getTransitionName(
+ transitionName: MotionName,
+ transitionType: string
+) {
+ if (!transitionName) return null;
+
+ if (typeof transitionName === 'object') {
+ const type = transitionType.replace(/-\w/g, (match) =>
+ match[1].toUpperCase()
+ );
+ return (transitionName as any)[type];
+ }
+
+ return `${transitionName}-${transitionType}`;
+}
diff --git a/src/components/Table/ExpandIcon.tsx b/src/components/Table/ExpandIcon.tsx
new file mode 100644
index 000000000..c17d0f902
--- /dev/null
+++ b/src/components/Table/ExpandIcon.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { mergeClasses } from '../../shared/utilities';
+
+import styles from './Styles/table.module.scss';
+
+interface DefaultExpandIconProps {
+ onExpand: (record: RecordType, e: React.MouseEvent) => void;
+ record: RecordType;
+ expanded: boolean;
+ expandable: boolean;
+}
+
+function renderExpandIcon(collapseText: string, expandText: string) {
+ return function expandIcon({
+ onExpand,
+ record,
+ expanded,
+ expandable,
+ }: DefaultExpandIconProps) {
+ return (
+ {
+ onExpand(record, e!);
+ e.stopPropagation();
+ }}
+ className={mergeClasses([
+ styles.tableRowExpandIcon,
+ { [styles.tableRowExpandIconSpaced]: !expandable },
+ { [styles.tableExpandedRow]: expandable && expanded },
+ {
+ [styles.tableRowExpandIconCollapsed]:
+ expandable && !expanded,
+ },
+ ])}
+ aria-label={expanded ? collapseText : expandText}
+ />
+ );
+ };
+}
+
+export default renderExpandIcon;
diff --git a/src/components/Table/Hooks/useFilter/FilterDropdown.tsx b/src/components/Table/Hooks/useFilter/FilterDropdown.tsx
new file mode 100644
index 000000000..4f951aad9
--- /dev/null
+++ b/src/components/Table/Hooks/useFilter/FilterDropdown.tsx
@@ -0,0 +1,502 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { mergeClasses } from '../../../../shared/utilities';
+import { isEqual } from '@ngard/tiny-isequal';
+import {
+ ButtonSize,
+ DefaultButton,
+ NeutralButton,
+ PrimaryButton,
+} from '../../../Button';
+import { Menu } from '../../../Menu';
+import type { MenuProps } from '../../../Menu';
+import Tree from '../../../Tree';
+import type { DataNode, EventDataNode } from '../../../Tree';
+import { CheckBox, RadioButton } from '../../../Selectors';
+import { Dropdown } from '../../../Dropdown';
+import { Empty } from '../../../Empty';
+import type {
+ ColumnType,
+ ColumnFilterItem,
+ Key,
+ GetPopupContainer,
+ FilterSearchType,
+} from '../../Table.types';
+import FilterDropdownMenuWrapper from './FilterWrapper';
+import FilterSearch from './FilterSearch';
+import type { FilterState } from './index';
+import { flattenKeys } from './index';
+import { useSyncState } from '../../../../hooks/useSyncState';
+import { IconName, IconSize } from '../../../Icon';
+import { useCanvasDirection } from '../../../../hooks/useCanvasDirection';
+
+import styles from '../../Styles/table.module.scss';
+
+interface FilterRestProps {
+ confirm?: Boolean;
+ closeDropdown?: Boolean;
+}
+
+function hasSubMenu(filters: ColumnFilterItem[]) {
+ return filters.some(({ children }) => children);
+}
+
+function searchValueMatched(searchValue: string, text: React.ReactNode) {
+ if (typeof text === 'string' || typeof text === 'number') {
+ return text
+ ?.toString()
+ .toLowerCase()
+ .includes(searchValue.trim().toLowerCase());
+ }
+ return false;
+}
+
+function renderFilterItems({
+ filters,
+ filteredKeys,
+ filterMultiple,
+ searchValue,
+ filterSearch,
+}: {
+ filters: ColumnFilterItem[];
+ filteredKeys: Key[];
+ filterMultiple: boolean;
+ searchValue: string;
+ filterSearch: FilterSearchType;
+}): Required['items'] {
+ return filters.map((filter, index: number) => {
+ const key = String(filter.value);
+
+ if (filter.children) {
+ return {
+ key: key || index,
+ value: filter.text,
+ popupClassName: styles.tableFilterDropdownSubmenu,
+ children: renderFilterItems({
+ filters: filter.children,
+ filteredKeys,
+ filterMultiple,
+ searchValue,
+ filterSearch,
+ }),
+ };
+ }
+
+ const Component = filterMultiple ? CheckBox : RadioButton;
+
+ const item = {
+ key: filter.value !== undefined ? key : index,
+ value: (
+ <>
+
+ {filter.text}
+ >
+ ),
+ };
+ if (searchValue.trim()) {
+ if (typeof filterSearch === 'function') {
+ return filterSearch(searchValue, filter) ? item : null;
+ }
+ return searchValueMatched(searchValue, filter.text) ? item : null;
+ }
+ return item;
+ });
+}
+
+export interface FilterDropdownProps {
+ column: ColumnType;
+ filterCheckallText: string;
+ filterConfirmText: string;
+ filterEmptyText: string;
+ filterResetText: string;
+ filterState?: FilterState;
+ filterMultiple: boolean;
+ filterMode?: 'menu' | 'tree';
+ filterSearch?: FilterSearchType;
+ filterSearchPlaceholderText: string;
+ columnKey: Key;
+ children: React.ReactNode;
+ triggerFilter: (filterState: FilterState) => void;
+ getPopupContainer?: GetPopupContainer;
+ filterResetToDefaultFilteredValue?: boolean;
+}
+
+function FilterDropdown(props: FilterDropdownProps) {
+ const {
+ column,
+ columnKey,
+ filterCheckallText = 'Select all items',
+ filterConfirmText = 'OK',
+ filterEmptyText = 'No filters',
+ filterResetText = 'Reset',
+ filterMultiple,
+ filterMode = 'menu',
+ filterSearch = false,
+ filterSearchPlaceholderText = 'Search in filters',
+ filterState,
+ triggerFilter,
+ children,
+ getPopupContainer,
+ } = props;
+
+ const {
+ filterDropdownVisible,
+ onFilterDropdownVisibleChange,
+ filterResetToDefaultFilteredValue,
+ defaultFilteredValue,
+ } = column;
+ const [visible, setVisible] = useState(false);
+
+ const filtered: boolean = !!(
+ filterState &&
+ (filterState.filteredKeys?.length || filterState.forceFiltered)
+ );
+ const triggerVisible = (newVisible: boolean) => {
+ setVisible(newVisible);
+ onFilterDropdownVisibleChange?.(newVisible);
+ };
+
+ const mergedVisible =
+ typeof filterDropdownVisible === 'boolean'
+ ? filterDropdownVisible
+ : visible;
+
+ // ===================== Select Keys =====================
+ const propFilteredKeys = filterState?.filteredKeys;
+ const [getFilteredKeysSync, setFilteredKeysSync] = useSyncState(
+ propFilteredKeys || []
+ );
+
+ const onSelectKeys = ({ selectedKeys }: { selectedKeys: Key[] }) => {
+ setFilteredKeysSync(selectedKeys);
+ };
+
+ const onCheck = (
+ keys: Key[],
+ { node, checked }: { node: EventDataNode; checked: boolean }
+ ) => {
+ if (!filterMultiple) {
+ onSelectKeys({
+ selectedKeys: checked && node.key ? [node.key] : [],
+ });
+ } else {
+ onSelectKeys({ selectedKeys: keys as Key[] });
+ }
+ };
+
+ useEffect(() => {
+ if (!visible) {
+ return;
+ }
+ onSelectKeys({ selectedKeys: propFilteredKeys || [] });
+ }, [propFilteredKeys]);
+
+ // ====================== Open Keys ======================
+ // const [openKeys, setOpenKeys] = React.useState([]);
+ const openRef = useRef();
+ // const onOpenChange = (keys: string[]) => {
+ // openRef.current = window.setTimeout(() => {
+ // setOpenKeys(keys);
+ // });
+ // };
+ const onMenuClick = () => {
+ window.clearTimeout(openRef.current);
+ };
+ useEffect(
+ () => () => {
+ window.clearTimeout(openRef.current);
+ },
+ []
+ );
+
+ // search in tree mode column filter
+ const [searchValue, setSearchValue] = useState('');
+ const onSearch = (e: React.ChangeEvent) => {
+ const { value } = e.target;
+ setSearchValue(value);
+ };
+ // clear search value after close filter dropdown
+ useEffect(() => {
+ if (!visible) {
+ setSearchValue('');
+ }
+ }, [visible]);
+
+ // ======================= Submit ========================
+ const internalTriggerFilter = (keys: Key[] | undefined | null): any => {
+ const mergedKeys = keys && keys.length ? keys : null;
+ if (
+ mergedKeys === null &&
+ (!filterState || !filterState.filteredKeys)
+ ) {
+ return null;
+ }
+
+ if (isEqual(mergedKeys, filterState?.filteredKeys)) {
+ return null;
+ }
+
+ triggerFilter({
+ column,
+ key: columnKey,
+ filteredKeys: mergedKeys,
+ });
+ };
+
+ const onConfirm = () => {
+ triggerVisible(false);
+ internalTriggerFilter(getFilteredKeysSync());
+ };
+
+ const onReset = (
+ { confirm, closeDropdown }: FilterRestProps = {
+ confirm: false,
+ closeDropdown: false,
+ }
+ ) => {
+ if (confirm) {
+ internalTriggerFilter([]);
+ }
+ if (closeDropdown) {
+ triggerVisible(false);
+ }
+
+ setSearchValue('');
+
+ if (filterResetToDefaultFilteredValue) {
+ setFilteredKeysSync(
+ (defaultFilteredValue || []).map((key) => String(key))
+ );
+ } else {
+ setFilteredKeysSync([]);
+ }
+ };
+
+ const doFilter = ({ closeDropdown } = { closeDropdown: true }) => {
+ if (closeDropdown) {
+ triggerVisible(false);
+ }
+ internalTriggerFilter(getFilteredKeysSync());
+ };
+
+ const onVisibleChange = (newVisible: boolean) => {
+ if (newVisible && propFilteredKeys !== undefined) {
+ // Sync filteredKeys on appear in controlled mode (propFilteredKeys !== undefiend)
+ setFilteredKeysSync(propFilteredKeys || []);
+ }
+
+ triggerVisible(newVisible);
+
+ // Default will filter when closed
+ if (!newVisible && !column.filterDropdown) {
+ onConfirm();
+ }
+ };
+
+ // ======================== Style ========================
+ const dropdownMenuClass: string = mergeClasses([
+ { ['table-menu-without-submenu']: !hasSubMenu(column.filters || []) },
+ ]);
+
+ const onCheckAll = (e: React.ChangeEvent) => {
+ if (e.target.checked) {
+ const allFilterKeys = flattenKeys(column?.filters).map((key) =>
+ String(key)
+ );
+ setFilteredKeysSync(allFilterKeys);
+ } else {
+ setFilteredKeysSync([]);
+ }
+ };
+
+ const getTreeData = ({ filters }: { filters?: ColumnFilterItem[] }) =>
+ (filters || []).map((filter, index) => {
+ const key = String(filter.value);
+ const item: DataNode = {
+ title: filter.text,
+ key: filter.value !== undefined ? key : index,
+ };
+ if (filter.children) {
+ item.children = getTreeData({ filters: filter.children });
+ }
+ return item;
+ });
+
+ let dropdownContent: React.ReactNode;
+ if (typeof column.filterDropdown === 'function') {
+ dropdownContent = column.filterDropdown({
+ setSelectedKeys: (selectedKeys: Key[]) =>
+ onSelectKeys({ selectedKeys }),
+ selectedKeys: getFilteredKeysSync(),
+ confirm: doFilter,
+ clearFilters: onReset,
+ filters: column.filters,
+ visible: mergedVisible,
+ });
+ } else if (column.filterDropdown) {
+ dropdownContent = column.filterDropdown;
+ } else {
+ const selectedKeys = (getFilteredKeysSync() || []) as any;
+ const getFilterComponent = () => {
+ if ((column.filters || []).length === 0) {
+ return (
+
+ );
+ }
+ if (filterMode === 'tree') {
+ return (
+ <>
+
+
+ {filterMultiple ? (
+
+ ) : null}
+ ) =>
+ searchValueMatched(
+ searchValue,
+ node.title
+ )
+ : undefined
+ }
+ />
+
+ >
+ );
+ }
+ return (
+ <>
+
+ onSelectKeys}
+ items={renderFilterItems({
+ filters: column.filters || [],
+ filterSearch,
+ filteredKeys: getFilteredKeysSync(),
+ filterMultiple,
+ searchValue,
+ })}
+ />
+ >
+ );
+ };
+
+ const getResetDisabled = () => {
+ if (filterResetToDefaultFilteredValue) {
+ return isEqual(
+ (defaultFilteredValue || []).map((key) => String(key)),
+ selectedKeys
+ );
+ }
+
+ return selectedKeys.length === 0;
+ };
+
+ dropdownContent = (
+ <>
+ {getFilterComponent()}
+
+
onReset()}
+ size={ButtonSize.Small}
+ text={filterResetText}
+ />
+
+
+ >
+ );
+ }
+
+ const menu = (
+
+ {dropdownContent}
+
+ );
+
+ const htmlDir: string = useCanvasDirection();
+
+ return (
+
+ {children}
+
+ {
+ e.stopPropagation();
+ }}
+ size={ButtonSize.Small}
+ />
+
+
+ );
+}
+
+export default FilterDropdown;
diff --git a/src/components/Table/Hooks/useFilter/FilterSearch.tsx b/src/components/Table/Hooks/useFilter/FilterSearch.tsx
new file mode 100644
index 000000000..39f8006e4
--- /dev/null
+++ b/src/components/Table/Hooks/useFilter/FilterSearch.tsx
@@ -0,0 +1,36 @@
+import React, { FC } from 'react';
+import { TextInput } from '../../../Inputs';
+import { IconName } from '../../../Icon';
+import type { FilterSearchType } from '../../Table.types';
+
+import styles from '../../Styles/table.module.scss';
+
+interface FilterSearchProps {
+ onChange: (e: React.ChangeEvent) => void;
+ filterSearch: FilterSearchType;
+ filterSearchPlaceholderText: string;
+}
+
+const FilterSearch: FC = ({
+ onChange,
+ filterSearch,
+ filterSearchPlaceholderText,
+}) => {
+ if (!filterSearch) {
+ return null;
+ }
+ return (
+
+
+
+ );
+};
+
+export default FilterSearch;
diff --git a/src/components/Table/Hooks/useFilter/FilterWrapper.tsx b/src/components/Table/Hooks/useFilter/FilterWrapper.tsx
new file mode 100644
index 000000000..6c00a4175
--- /dev/null
+++ b/src/components/Table/Hooks/useFilter/FilterWrapper.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+
+export interface FilterDropdownMenuWrapperProps {
+ children?: React.ReactNode;
+ classNames?: string;
+}
+
+const FilterDropdownMenuWrapper = (props: FilterDropdownMenuWrapperProps) => (
+ e.stopPropagation()}>
+ {props.children}
+
+);
+
+export default FilterDropdownMenuWrapper;
diff --git a/src/components/Table/Hooks/useFilter/index.tsx b/src/components/Table/Hooks/useFilter/index.tsx
new file mode 100644
index 000000000..1d51be57b
--- /dev/null
+++ b/src/components/Table/Hooks/useFilter/index.tsx
@@ -0,0 +1,291 @@
+import React, { useCallback, useMemo, useState } from 'react';
+import type {
+ TransformColumns,
+ ColumnsType,
+ ColumnType,
+ ColumnTitleProps,
+ Key,
+ FilterValue,
+ FilterKey,
+ GetPopupContainer,
+ ColumnFilterItem,
+} from '../../Table.types';
+import { getColumnPos, renderColumnTitle, getColumnKey } from '../../utlities';
+import FilterDropdown from './FilterDropdown';
+
+export interface FilterState {
+ column: ColumnType;
+ key: Key;
+ filteredKeys?: FilterKey;
+ forceFiltered?: boolean;
+}
+
+function collectFilterStates(
+ columns: ColumnsType,
+ init: boolean,
+ pos?: string
+): FilterState[] {
+ let filterStates: FilterState[] = [];
+
+ (columns || []).forEach((column, index) => {
+ const columnPos = getColumnPos(index, pos);
+
+ if (
+ column.filters ||
+ 'filterDropdown' in column ||
+ 'onFilter' in column
+ ) {
+ if ('filteredValue' in column) {
+ // Controlled
+ let filteredValues = column.filteredValue;
+ if (!('filterDropdown' in column)) {
+ filteredValues =
+ filteredValues?.map(String) ?? filteredValues;
+ }
+ filterStates.push({
+ column,
+ key: getColumnKey(column, columnPos),
+ filteredKeys: filteredValues as FilterKey,
+ forceFiltered: column.filtered,
+ });
+ } else {
+ // Uncontrolled
+ filterStates.push({
+ column,
+ key: getColumnKey(column, columnPos),
+ filteredKeys: (init && column.defaultFilteredValue
+ ? column.defaultFilteredValue!
+ : undefined) as FilterKey,
+ forceFiltered: column.filtered,
+ });
+ }
+ }
+
+ if ('children' in column) {
+ filterStates = [
+ ...filterStates,
+ ...collectFilterStates(column.children, init, columnPos),
+ ];
+ }
+ });
+
+ return filterStates;
+}
+
+function injectFilter(
+ columns: ColumnsType,
+ filterCheckallText: string,
+ filterConfirmText: string,
+ filterEmptyText: string,
+ filterResetText: string,
+ filterSearchPlaceholderText: string,
+ filterStates: FilterState[],
+ triggerFilter: (filterState: FilterState) => void,
+ getPopupContainer: GetPopupContainer | undefined,
+ pos?: string
+): ColumnsType {
+ return columns.map((column, index) => {
+ const columnPos = getColumnPos(index, pos);
+ const {
+ filterMultiple = true,
+ filterMode,
+ filterSearch,
+ } = column as ColumnType;
+
+ let newColumn: ColumnsType[number] = column;
+
+ if (newColumn.filters || newColumn.filterDropdown) {
+ const columnKey = getColumnKey(newColumn, columnPos);
+ const filterState = filterStates.find(
+ ({ key }) => columnKey === key
+ );
+
+ newColumn = {
+ ...newColumn,
+ title: (renderProps: ColumnTitleProps) => (
+
+ {renderColumnTitle(column.title, renderProps)}
+
+ ),
+ };
+ }
+
+ if ('children' in newColumn) {
+ newColumn = {
+ ...newColumn,
+ children: injectFilter(
+ newColumn.children,
+ filterCheckallText,
+ filterConfirmText,
+ filterEmptyText,
+ filterResetText,
+ filterSearchPlaceholderText,
+ filterStates,
+ triggerFilter,
+ getPopupContainer,
+ columnPos
+ ),
+ };
+ }
+
+ return newColumn;
+ });
+}
+
+export function flattenKeys(filters?: ColumnFilterItem[]) {
+ let keys: FilterValue = [];
+ (filters || []).forEach(({ value, children }) => {
+ keys.push(value);
+ if (children) {
+ keys = [...keys, ...flattenKeys(children)];
+ }
+ });
+ return keys;
+}
+
+function generateFilterInfo(
+ filterStates: FilterState[]
+) {
+ const currentFilters: Record = {};
+
+ filterStates.forEach(({ key, filteredKeys, column }) => {
+ const { filters, filterDropdown } = column;
+ if (filterDropdown) {
+ currentFilters[key] = filteredKeys || null;
+ } else if (Array.isArray(filteredKeys)) {
+ const keys = flattenKeys(filters);
+ currentFilters[key] = keys.filter((originKey) =>
+ filteredKeys.includes(String(originKey))
+ );
+ } else {
+ currentFilters[key] = null;
+ }
+ });
+
+ return currentFilters;
+}
+
+export function getFilterData(
+ data: RecordType[],
+ filterStates: FilterState[]
+) {
+ return filterStates.reduce((currentData, filterState) => {
+ const {
+ column: { onFilter, filters },
+ filteredKeys,
+ } = filterState;
+ if (onFilter && filteredKeys && filteredKeys.length) {
+ return currentData.filter((record) =>
+ filteredKeys.some((key) => {
+ const keys = flattenKeys(filters);
+ const keyIndex = keys.findIndex(
+ (k) => String(k) === String(key)
+ );
+ const realKey = keyIndex !== -1 ? keys[keyIndex] : key;
+ return onFilter(realKey, record);
+ })
+ );
+ }
+ return currentData;
+ }, data);
+}
+
+interface FilterConfig {
+ mergedColumns: ColumnsType;
+ filterCheckallText: string;
+ filterConfirmText: string;
+ filterEmptyText: string;
+ filterResetText: string;
+ filterSearchPlaceholderText: string;
+ onFilterChange: (
+ filters: Record,
+ filterStates: FilterState[]
+ ) => void;
+ getPopupContainer?: GetPopupContainer;
+}
+
+function useFilter({
+ mergedColumns,
+ filterCheckallText,
+ filterConfirmText,
+ filterEmptyText,
+ filterResetText,
+ filterSearchPlaceholderText,
+ onFilterChange,
+ getPopupContainer,
+}: FilterConfig): [
+ TransformColumns,
+ FilterState[],
+ () => Record
+] {
+ const [filterStates, setFilterStates] = useState[]>(
+ collectFilterStates(mergedColumns, true)
+ );
+
+ const mergedFilterStates = useMemo(() => {
+ const collectedStates = collectFilterStates(mergedColumns, false);
+ let filteredKeysIsAllNotControlled = true;
+ let filteredKeysIsAllControlled = true;
+ collectedStates.forEach(({ filteredKeys }) => {
+ if (filteredKeys !== undefined) {
+ filteredKeysIsAllNotControlled = false;
+ } else {
+ filteredKeysIsAllControlled = false;
+ }
+ });
+
+ // Return if not controlled
+ if (filteredKeysIsAllNotControlled) {
+ return filterStates;
+ }
+
+ return collectedStates;
+ }, [mergedColumns, filterStates]);
+
+ const getFilters = useCallback(
+ () => generateFilterInfo(mergedFilterStates),
+ [mergedFilterStates]
+ );
+
+ const triggerFilter = (filterState: FilterState) => {
+ const newFilterStates = mergedFilterStates.filter(
+ ({ key }) => key !== filterState.key
+ );
+ newFilterStates.push(filterState);
+ setFilterStates(newFilterStates);
+ onFilterChange(generateFilterInfo(newFilterStates), newFilterStates);
+ };
+
+ const transformColumns = (innerColumns: ColumnsType) =>
+ injectFilter(
+ innerColumns,
+ filterCheckallText,
+ filterConfirmText,
+ filterEmptyText,
+ filterResetText,
+ filterSearchPlaceholderText,
+ mergedFilterStates,
+ triggerFilter,
+ getPopupContainer
+ );
+
+ return [transformColumns, mergedFilterStates, getFilters];
+}
+
+export default useFilter;
diff --git a/src/components/Table/Hooks/useLazyKVMap.ts b/src/components/Table/Hooks/useLazyKVMap.ts
new file mode 100644
index 000000000..5007119ca
--- /dev/null
+++ b/src/components/Table/Hooks/useLazyKVMap.ts
@@ -0,0 +1,59 @@
+import { useRef } from 'react';
+import type { Key, GetRowKey } from '../Table.types';
+
+interface MapCache {
+ data?: readonly RecordType[];
+ childrenColumnName?: string;
+ kvMap?: Map;
+ getRowKey?: Function;
+}
+
+export default function useLazyKVMap(
+ data: readonly RecordType[],
+ childrenColumnName: string,
+ getRowKey: GetRowKey
+) {
+ const mapCacheRef = useRef>({});
+
+ function dig(
+ records: readonly RecordType[],
+ kvMap?: Map
+ ): void {
+ records.forEach((record, index) => {
+ const rowKey = getRowKey(record, index);
+ kvMap?.set(rowKey, record);
+
+ if (
+ record &&
+ typeof record === 'object' &&
+ childrenColumnName in record
+ ) {
+ dig((record as any)[childrenColumnName] || []);
+ }
+ });
+ }
+
+ function getRecordByKey(key: Key): RecordType {
+ if (
+ !mapCacheRef.current ||
+ mapCacheRef.current.data !== data ||
+ mapCacheRef.current.childrenColumnName !== childrenColumnName ||
+ mapCacheRef.current.getRowKey !== getRowKey
+ ) {
+ const kvMap = new Map();
+
+ dig(data, kvMap);
+
+ mapCacheRef.current = {
+ data,
+ childrenColumnName,
+ kvMap,
+ getRowKey,
+ };
+ }
+
+ return mapCacheRef.current.kvMap!.get(key)!;
+ }
+
+ return [getRecordByKey];
+}
diff --git a/src/components/Table/Hooks/usePagination.ts b/src/components/Table/Hooks/usePagination.ts
new file mode 100644
index 000000000..57a70e162
--- /dev/null
+++ b/src/components/Table/Hooks/usePagination.ts
@@ -0,0 +1,141 @@
+import { useState } from 'react';
+import type { PaginationProps } from '../../Pagination/Pagination.types';
+import type { TablePaginationConfig } from '../Table.types';
+
+export const DEFAULT_PAGE_SIZE: number = 10;
+
+export function getPaginationParam(
+ pagination: TablePaginationConfig | boolean | undefined,
+ mergedPagination: TablePaginationConfig
+) {
+ const param: any = {
+ currentPage: mergedPagination.currentPage,
+ pageSize: mergedPagination.pageSize,
+ total: mergedPagination.total,
+ };
+
+ const paginationObj =
+ pagination && typeof pagination === 'object' ? pagination : {};
+
+ Object.keys(paginationObj).forEach((pageProp) => {
+ const value = (mergedPagination as any)[pageProp];
+
+ if (typeof value !== 'function') {
+ param[pageProp] = value;
+ }
+ });
+
+ return param;
+}
+
+function extendsObject(...list: T[]) {
+ const result: T = {} as T;
+
+ list.forEach((obj) => {
+ if (obj) {
+ Object.keys(obj).forEach((key) => {
+ const val = (obj as any)[key];
+ if (val !== undefined) {
+ (result as any)[key] = val;
+ }
+ });
+ }
+ });
+
+ return result;
+}
+
+export default function usePagination(
+ total: number,
+ pagination: TablePaginationConfig | false | undefined,
+ onChange: (currentPage: number, pageSize: number, total: number) => void
+): [TablePaginationConfig, () => void] {
+ const { total: paginationTotal = 0, ...paginationObj } =
+ pagination && typeof pagination === 'object' ? pagination : {};
+
+ const [innerPagination, setInnerPagination] = useState<{
+ currentPage?: number;
+ pageSize?: number;
+ total?: number;
+ }>(() => ({
+ currentPage: paginationObj.currentPage,
+ pageSize: paginationObj.pageSize,
+ total: paginationTotal,
+ }));
+
+ // ============ Basic Pagination Config ============
+ const mergedPagination = extendsObject>(
+ innerPagination,
+ paginationObj,
+ {
+ total: paginationTotal > 0 ? paginationTotal : total,
+ }
+ );
+
+ // Reset `current` if data length or pageSize changed
+ const maxPage = Math.ceil(
+ (paginationTotal || total) / mergedPagination.pageSize!
+ );
+ if (mergedPagination.currentPage! > maxPage) {
+ // Prevent a maximum page count of 0
+ mergedPagination.currentPage = maxPage || 1;
+ }
+
+ const refreshPagination = (
+ currentPage?: number,
+ pageSize?: number,
+ total?: number
+ ) => {
+ setInnerPagination({
+ currentPage: currentPage,
+ pageSize: pageSize,
+ total: total,
+ });
+ };
+
+ const onInternalCurrentChange: PaginationProps['onCurrentChange'] = (
+ currentPage: number
+ ) => {
+ mergedPagination.onCurrentChange?.(mergedPagination?.currentPage);
+ refreshPagination(
+ currentPage,
+ mergedPagination?.pageSize,
+ mergedPagination?.total
+ );
+ onChange(
+ currentPage || mergedPagination?.currentPage!,
+ mergedPagination?.pageSize,
+ mergedPagination?.total
+ );
+ };
+
+ const onInternalSizeChange: PaginationProps['onSizeChange'] = (
+ pageSize: number
+ ) => {
+ mergedPagination.onSizeChange?.(mergedPagination?.pageSize);
+ refreshPagination(
+ mergedPagination?.currentPage,
+ pageSize,
+ mergedPagination?.total
+ );
+ onChange(
+ mergedPagination?.currentPage,
+ pageSize || mergedPagination?.pageSize!,
+ mergedPagination?.total
+ );
+ };
+
+ if (pagination === false) {
+ return [{}, () => {}];
+ }
+
+ return [
+ {
+ ...mergedPagination,
+ onCurrentChange: onInternalCurrentChange,
+ onSizeChange: onInternalSizeChange,
+ total: mergedPagination.total,
+ },
+ refreshPagination,
+ ];
+}
diff --git a/src/components/Table/Hooks/useSelection.tsx b/src/components/Table/Hooks/useSelection.tsx
new file mode 100644
index 000000000..c2f3d0d94
--- /dev/null
+++ b/src/components/Table/Hooks/useSelection.tsx
@@ -0,0 +1,758 @@
+import React, { useEffect, useRef } from 'react';
+import { useState, useCallback, useMemo } from 'react';
+import { convertDataToEntities } from '../../Tree/Internal/utils/treeUtil';
+import { conductCheck } from '../../Tree/Internal/utils/conductUtil';
+import { arrAdd, arrDel } from '../../Tree/Internal/util';
+import type {
+ DataNode,
+ GetCheckDisabled,
+} from '../../Tree/Internal/OcTree.types';
+import { INTERNAL_COL_DEFINE } from '../Internal';
+import type { FixedType } from '../Internal/OcTable.types';
+import { useMergedState } from '../../../hooks/useMergedState';
+import type { CheckboxProps } from '../../Selectors';
+import { CheckBox, RadioButton } from '../../Selectors';
+import { Dropdown } from '../../Dropdown';
+import { Icon, IconName, IconSize } from '../../Icon';
+import { Menu } from '../../Menu';
+import type {
+ TableRowSelection,
+ Key,
+ ColumnsType,
+ ColumnType,
+ GetRowKey,
+ SelectionItem,
+ TransformColumns,
+ ExpandType,
+ GetPopupContainer,
+} from '../Table.types';
+
+import styles from '../Styles/table.module.scss';
+
+export const SELECTION_COLUMN = {} as const;
+export const SELECTION_ALL = 'SELECT_ALL' as const;
+export const SELECTION_INVERT = 'SELECT_INVERT' as const;
+export const SELECTION_NONE = 'SELECT_NONE' as const;
+
+const EMPTY_LIST: React.Key[] = [];
+
+interface UseSelectionConfig {
+ pageData: RecordType[];
+ data: RecordType[];
+ getRowKey: GetRowKey;
+ getRecordByKey: (key: Key) => RecordType;
+ expandType: ExpandType;
+ childrenColumnName: string;
+ emptyText: React.ReactNode | (() => React.ReactNode);
+ emptyTextDetails?: string;
+ selectionAllText: string;
+ selectInvertText: string;
+ selectNoneText: string;
+ getPopupContainer?: GetPopupContainer;
+}
+
+export type INTERNAL_SELECTION_ITEM =
+ | SelectionItem
+ | typeof SELECTION_ALL
+ | typeof SELECTION_INVERT
+ | typeof SELECTION_NONE;
+
+function flattenData(
+ data: RecordType[] | undefined,
+ childrenColumnName: string
+): RecordType[] {
+ let list: RecordType[] = [];
+ (data || []).forEach((record) => {
+ list.push(record);
+
+ if (
+ record &&
+ typeof record === 'object' &&
+ childrenColumnName in record
+ ) {
+ list = [
+ ...list,
+ ...flattenData(
+ (record as any)[childrenColumnName],
+ childrenColumnName
+ ),
+ ];
+ }
+ });
+
+ return list;
+}
+
+export default function useSelection(
+ rowSelection: TableRowSelection | undefined,
+ config: UseSelectionConfig
+): [TransformColumns, Set] {
+ const {
+ preserveSelectedRowKeys,
+ selectedRowKeys,
+ defaultSelectedRowKeys,
+ getCheckboxProps,
+ onChange: onSelectionChange,
+ onSelect,
+ columnWidth: selectionColWidth,
+ type: selectionType,
+ selections,
+ fixed,
+ renderCell: customizeRenderCell,
+ hideSelectAll,
+ checkStrictly = true,
+ } = rowSelection || {};
+
+ const {
+ data,
+ pageData,
+ getRecordByKey,
+ getRowKey,
+ expandType,
+ childrenColumnName,
+ selectionAllText,
+ selectInvertText,
+ selectNoneText,
+ getPopupContainer,
+ } = config;
+
+ // ========================= Keys =========================
+ const [mergedSelectedKeys, setMergedSelectedKeys] = useMergedState(
+ selectedRowKeys || defaultSelectedRowKeys || EMPTY_LIST,
+ {
+ value: selectedRowKeys,
+ }
+ );
+
+ // ======================== Caches ========================
+ const preserveRecordsRef = useRef(new Map());
+
+ const updatePreserveRecordsCache = useCallback(
+ (keys: Key[]) => {
+ if (preserveSelectedRowKeys) {
+ const newCache = new Map();
+ // Keep key if mark as preserveSelectedRowKeys
+ keys.forEach((key) => {
+ let record = getRecordByKey(key);
+
+ if (!record && preserveRecordsRef.current.has(key)) {
+ record = preserveRecordsRef.current.get(key)!;
+ }
+
+ newCache.set(key, record);
+ });
+ // Refresh to new cache
+ preserveRecordsRef.current = newCache;
+ }
+ },
+ [getRecordByKey, preserveSelectedRowKeys]
+ );
+
+ // Update cache with selectedKeys
+ useEffect(() => {
+ updatePreserveRecordsCache(mergedSelectedKeys);
+ }, [mergedSelectedKeys]);
+
+ const { keyEntities } = useMemo(
+ () =>
+ checkStrictly
+ ? { keyEntities: null }
+ : convertDataToEntities(data as unknown as DataNode[], {
+ externalGetKey: getRowKey as any,
+ childrenPropName: childrenColumnName,
+ }),
+ [data, getRowKey, checkStrictly, childrenColumnName]
+ );
+
+ // Get flatten data
+ const flattedData = useMemo(
+ () => flattenData(pageData, childrenColumnName),
+ [pageData, childrenColumnName]
+ );
+
+ // Get all checkbox props
+ const checkboxPropsMap = useMemo(() => {
+ const map = new Map>();
+ flattedData.forEach((record, index) => {
+ const key = getRowKey(record, index);
+ const checkboxProps =
+ (getCheckboxProps ? getCheckboxProps(record) : null) || {};
+ map.set(key, checkboxProps);
+ });
+ return map;
+ }, [flattedData, getRowKey, getCheckboxProps]);
+
+ const isCheckboxDisabled: GetCheckDisabled = useCallback(
+ (r: RecordType) => !!checkboxPropsMap.get(getRowKey(r))?.disabled,
+ [checkboxPropsMap, getRowKey]
+ );
+
+ const [derivedSelectedKeys, derivedHalfSelectedKeys] = useMemo(() => {
+ if (checkStrictly) {
+ return [mergedSelectedKeys || [], []];
+ }
+ const { checkedKeys, halfCheckedKeys } = conductCheck(
+ mergedSelectedKeys,
+ true,
+ keyEntities as any,
+ isCheckboxDisabled as any
+ );
+ return [checkedKeys || [], halfCheckedKeys];
+ }, [mergedSelectedKeys, checkStrictly, keyEntities, isCheckboxDisabled]);
+
+ const derivedSelectedKeySet: Set = useMemo(() => {
+ const keys =
+ selectionType === 'radio'
+ ? derivedSelectedKeys.slice(0, 1)
+ : derivedSelectedKeys;
+ return new Set(keys);
+ }, [derivedSelectedKeys, selectionType]);
+ const derivedHalfSelectedKeySet = useMemo(
+ () =>
+ selectionType === 'radio'
+ ? new Set()
+ : new Set(derivedHalfSelectedKeys),
+ [derivedHalfSelectedKeys, selectionType]
+ );
+
+ // Save last selected key to enable range selection
+ const [lastSelectedKey, setLastSelectedKey] = useState(null);
+
+ // Reset if rowSelection reset
+ useEffect(() => {
+ if (!rowSelection) {
+ setMergedSelectedKeys(EMPTY_LIST);
+ }
+ }, [!!rowSelection]);
+
+ const setSelectedKeys = useCallback(
+ (keys: Key[]) => {
+ let availableKeys: Key[];
+ let records: RecordType[];
+
+ updatePreserveRecordsCache(keys);
+
+ if (preserveSelectedRowKeys) {
+ availableKeys = keys;
+ records = keys.map(
+ (key) => preserveRecordsRef.current.get(key)!
+ );
+ } else {
+ // Filter key which not exist in the `dataSource`
+ availableKeys = [];
+ records = [];
+
+ keys.forEach((key) => {
+ const record = getRecordByKey(key);
+ if (record !== undefined) {
+ availableKeys.push(key);
+ records.push(record);
+ }
+ });
+ }
+
+ setMergedSelectedKeys(availableKeys);
+
+ onSelectionChange?.(availableKeys, records);
+ },
+ [
+ setMergedSelectedKeys,
+ getRecordByKey,
+ onSelectionChange,
+ preserveSelectedRowKeys,
+ ]
+ );
+
+ // ====================== Selections ======================
+ // Trigger single `onSelect` event
+ const triggerSingleSelection = useCallback(
+ (key: Key, selected: boolean, keys: Key[], event: Event) => {
+ if (onSelect) {
+ const rows = keys.map((k) => getRecordByKey(k));
+ onSelect(getRecordByKey(key), selected, rows, event);
+ }
+
+ setSelectedKeys(keys);
+ },
+ [onSelect, getRecordByKey, setSelectedKeys]
+ );
+
+ const mergedSelections = useMemo((): any => {
+ if (!selections || hideSelectAll) {
+ return null;
+ }
+
+ const selectionList: INTERNAL_SELECTION_ITEM[] =
+ selections === true
+ ? [SELECTION_ALL, SELECTION_INVERT, SELECTION_NONE]
+ : selections;
+
+ return selectionList.map((selection: INTERNAL_SELECTION_ITEM) => {
+ if (selection === SELECTION_ALL) {
+ return {
+ key: 'all',
+ value: selectionAllText,
+ onSelect() {
+ setSelectedKeys(
+ data
+ .map((record, index) =>
+ getRowKey(record, index)
+ )
+ .filter((key) => {
+ const checkProps =
+ checkboxPropsMap.get(key);
+ return (
+ !checkProps?.disabled ||
+ derivedSelectedKeySet.has(key)
+ );
+ })
+ );
+ },
+ };
+ }
+ if (selection === SELECTION_INVERT) {
+ return {
+ key: 'invert',
+ value: selectInvertText,
+ onSelect() {
+ const keySet = new Set(derivedSelectedKeySet);
+ pageData.forEach((record, index) => {
+ const key = getRowKey(record, index);
+ const checkProps = checkboxPropsMap.get(key);
+
+ if (!checkProps?.disabled) {
+ if (keySet.has(key)) {
+ keySet.delete(key);
+ } else {
+ keySet.add(key);
+ }
+ }
+ });
+
+ const keys = Array.from(keySet);
+ setSelectedKeys(keys);
+ },
+ };
+ }
+ if (selection === SELECTION_NONE) {
+ return {
+ key: 'none',
+ value: selectNoneText,
+ onSelect() {
+ setSelectedKeys(
+ Array.from(derivedSelectedKeySet).filter((key) => {
+ const checkProps = checkboxPropsMap.get(key);
+ return checkProps?.disabled;
+ })
+ );
+ },
+ };
+ }
+ return selection as SelectionItem;
+ });
+ }, [
+ selections,
+ derivedSelectedKeySet,
+ pageData,
+ getRowKey,
+ setSelectedKeys,
+ ]);
+
+ // ======================= Columns ========================
+ const transformColumns = useCallback(
+ (columns: ColumnsType): ColumnsType => {
+ // >>>>>>>>>>> Skip if not exists `rowSelection`
+ if (!rowSelection) {
+ return columns.filter((col) => col !== SELECTION_COLUMN);
+ }
+
+ // >>>>>>>>>>> Support selection
+ let cloneColumns = [...columns];
+ const keySet = new Set(derivedSelectedKeySet);
+
+ // Record key only need check with enabled
+ const recordKeys = flattedData
+ .map(getRowKey)
+ .filter((key) => !checkboxPropsMap.get(key)!.disabled);
+ const checkedCurrentAll = recordKeys.every((key) =>
+ keySet.has(key)
+ );
+
+ const onSelectAllChange = () => {
+ const changeKeys: Key[] = [];
+
+ if (checkedCurrentAll) {
+ recordKeys.forEach((key) => {
+ keySet.delete(key);
+ changeKeys.push(key);
+ });
+ } else {
+ recordKeys.forEach((key) => {
+ if (!keySet.has(key)) {
+ keySet.add(key);
+ changeKeys.push(key);
+ }
+ });
+ }
+
+ const keys = Array.from(keySet);
+ setSelectedKeys(keys);
+ };
+
+ // ===================== Render =====================
+ // Title Cell
+ let title: React.ReactNode;
+ if (selectionType !== 'radio') {
+ let customizeSelections: React.ReactNode;
+ if (mergedSelections) {
+ const menu = (
+ {
+ const {
+ key,
+ text,
+ onSelect: onSelectionClick,
+ } = selection;
+
+ return {
+ key: key || index,
+ onClick: () => {
+ onSelectionClick?.(recordKeys);
+ },
+ value: text,
+ };
+ })}
+ />
+ );
+ customizeSelections = (
+
+
+
+
+
+
+
+ );
+ }
+
+ const allDisabledData = flattedData
+ .map((record, index) => {
+ const key = getRowKey(record, index);
+ const checkboxProps = checkboxPropsMap.get(key) || {};
+ return { checked: keySet.has(key), ...checkboxProps };
+ })
+ .filter(({ disabled }) => disabled);
+
+ const allDisabled =
+ !!allDisabledData.length &&
+ allDisabledData.length === flattedData.length;
+
+ const allDisabledAndChecked =
+ allDisabled &&
+ allDisabledData.every(({ checked }) => checked);
+ const allDisabledSomeChecked =
+ allDisabled &&
+ allDisabledData.some(({ checked }) => checked);
+
+ title = !hideSelectAll && (
+
+
+ {customizeSelections}
+
+ );
+ }
+
+ // Body Cell
+ let renderCell: (
+ _: RecordType,
+ record: RecordType,
+ index: number
+ ) => { node: React.ReactNode; checked: boolean };
+ if (selectionType === 'radio') {
+ renderCell = (_, record, index) => {
+ const key = getRowKey(record, index);
+ const checked = keySet.has(key);
+
+ return {
+ node: (
+ e.stopPropagation()}
+ onChange={(event) => {
+ if (!keySet.has(key)) {
+ triggerSingleSelection(
+ key,
+ true,
+ [key],
+ event.nativeEvent
+ );
+ }
+ }}
+ />
+ ),
+ checked,
+ };
+ };
+ } else {
+ renderCell = (_, record, index) => {
+ const key = getRowKey(record, index);
+ const checked = keySet.has(key);
+ const checkboxProps = checkboxPropsMap.get(key);
+
+ // Record checked
+ return {
+ node: (
+ e.stopPropagation()}
+ onChange={(nativeEvent: any) => {
+ const { shiftKey } = nativeEvent;
+
+ let startIndex: number = -1;
+ let endIndex: number = -1;
+
+ // Get range of this
+ if (shiftKey && checkStrictly) {
+ const pointKeys = new Set([
+ lastSelectedKey,
+ key,
+ ]);
+
+ recordKeys.some(
+ (recordKey, recordIndex) => {
+ if (pointKeys.has(recordKey)) {
+ if (startIndex === -1) {
+ startIndex =
+ recordIndex;
+ } else {
+ endIndex = recordIndex;
+ return true;
+ }
+ }
+
+ return false;
+ }
+ );
+ }
+
+ if (
+ endIndex !== -1 &&
+ startIndex !== endIndex &&
+ checkStrictly
+ ) {
+ // Batch update selections
+ const rangeKeys = recordKeys.slice(
+ startIndex,
+ endIndex + 1
+ );
+ const changedKeys: Key[] = [];
+
+ if (checked) {
+ rangeKeys.forEach((recordKey) => {
+ if (keySet.has(recordKey)) {
+ changedKeys.push(recordKey);
+ keySet.delete(recordKey);
+ }
+ });
+ } else {
+ rangeKeys.forEach((recordKey) => {
+ if (!keySet.has(recordKey)) {
+ changedKeys.push(recordKey);
+ keySet.add(recordKey);
+ }
+ });
+ }
+
+ const keys = Array.from(keySet);
+ setSelectedKeys(keys);
+ } else {
+ // Single record selected
+ const originCheckedKeys =
+ derivedSelectedKeys;
+ if (checkStrictly) {
+ const checkedKeys = checked
+ ? arrDel(originCheckedKeys, key)
+ : arrAdd(
+ originCheckedKeys,
+ key
+ );
+ triggerSingleSelection(
+ key,
+ !checked,
+ checkedKeys,
+ nativeEvent
+ );
+ } else {
+ // Always fill first
+ const result = conductCheck(
+ [...originCheckedKeys, key],
+ true,
+ keyEntities as any,
+ isCheckboxDisabled as any
+ );
+ const {
+ checkedKeys,
+ halfCheckedKeys,
+ } = result;
+ let nextCheckedKeys = checkedKeys;
+
+ // If remove, we do it again to correction
+ if (checked) {
+ const tempKeySet = new Set(
+ checkedKeys
+ );
+ tempKeySet.delete(key);
+ nextCheckedKeys = conductCheck(
+ Array.from(tempKeySet),
+ {
+ checked: false,
+ halfCheckedKeys,
+ },
+ keyEntities as any,
+ isCheckboxDisabled as any
+ ).checkedKeys;
+ }
+
+ triggerSingleSelection(
+ key,
+ !checked,
+ nextCheckedKeys,
+ nativeEvent
+ );
+ }
+ }
+
+ setLastSelectedKey(key);
+ }}
+ />
+ ),
+ checked,
+ };
+ };
+ }
+
+ const renderSelectionCell = (
+ _: any,
+ record: RecordType,
+ index: number
+ ) => {
+ const { node, checked } = renderCell(_, record, index);
+
+ if (customizeRenderCell) {
+ return customizeRenderCell(checked, record, index, node);
+ }
+
+ return node;
+ };
+
+ // Insert selection column if not exist
+ if (!cloneColumns.includes(SELECTION_COLUMN)) {
+ // Always after expand icon
+ if (
+ cloneColumns.findIndex(
+ (col: any) =>
+ col[INTERNAL_COL_DEFINE]?.columnType ===
+ 'EXPAND_COLUMN'
+ ) === 0
+ ) {
+ const [expandColumn, ...restColumns] = cloneColumns;
+ cloneColumns = [
+ expandColumn,
+ SELECTION_COLUMN,
+ ...restColumns,
+ ];
+ } else {
+ // Normal insert at first column
+ cloneColumns = [SELECTION_COLUMN, ...cloneColumns];
+ }
+ }
+
+ // Deduplicate selection column
+ const selectionColumnIndex = cloneColumns.indexOf(SELECTION_COLUMN);
+
+ cloneColumns = cloneColumns.filter(
+ (column, index) =>
+ column !== SELECTION_COLUMN ||
+ index === selectionColumnIndex
+ );
+
+ // Fixed column logic
+ const prevCol: ColumnType & Record =
+ cloneColumns[selectionColumnIndex - 1];
+ const nextCol: ColumnType & Record =
+ cloneColumns[selectionColumnIndex + 1];
+
+ let mergedFixed: FixedType | undefined = fixed;
+
+ if (mergedFixed === undefined) {
+ if (nextCol?.fixed !== undefined) {
+ mergedFixed = nextCol.fixed;
+ } else if (prevCol?.fixed !== undefined) {
+ mergedFixed = prevCol.fixed;
+ }
+ }
+
+ if (
+ mergedFixed &&
+ prevCol &&
+ prevCol[INTERNAL_COL_DEFINE]?.columnType === 'EXPAND_COLUMN' &&
+ prevCol.fixed === undefined
+ ) {
+ prevCol.fixed = mergedFixed;
+ }
+
+ // Replace with real selection column
+ const selectionColumn = {
+ fixed: mergedFixed,
+ width: selectionColWidth,
+ className: styles.tableSelectionColumn,
+ title: rowSelection.columnTitle || title,
+ render: renderSelectionCell,
+ [INTERNAL_COL_DEFINE]: {
+ className: styles.tableSelectionCol,
+ },
+ };
+
+ return cloneColumns.map((col) =>
+ col === SELECTION_COLUMN ? selectionColumn : col
+ );
+ },
+ [
+ getRowKey,
+ flattedData,
+ rowSelection,
+ derivedSelectedKeys,
+ derivedSelectedKeySet,
+ derivedHalfSelectedKeySet,
+ selectionColWidth,
+ mergedSelections,
+ expandType,
+ lastSelectedKey,
+ checkboxPropsMap,
+ triggerSingleSelection,
+ isCheckboxDisabled,
+ ]
+ );
+
+ return [transformColumns, derivedSelectedKeySet];
+}
diff --git a/src/components/Table/Hooks/useSorter.tsx b/src/components/Table/Hooks/useSorter.tsx
new file mode 100644
index 000000000..4c9d1486c
--- /dev/null
+++ b/src/components/Table/Hooks/useSorter.tsx
@@ -0,0 +1,528 @@
+import React, { useMemo, useState } from 'react';
+import { eventKeys, mergeClasses } from '../../../shared/utilities';
+import type {
+ TransformColumns,
+ ColumnsType,
+ Key,
+ ColumnType,
+ SortOrder,
+ CompareFn,
+ ColumnTitleProps,
+ SorterResult,
+ ColumnGroupType,
+} from '../Table.types';
+import type { TooltipProps } from '../../Tooltip';
+import { Tooltip, TooltipTheme } from '../../Tooltip';
+import { getColumnKey, getColumnPos, renderColumnTitle } from '../utlities';
+import { Icon, IconName, IconSize } from '../../Icon';
+
+import styles from '../Styles/table.module.scss';
+
+const ASCEND: SortOrder = 'ascend';
+const DESCEND: SortOrder = 'descend';
+
+function getMultiplePriority(
+ column: ColumnType
+): number | false {
+ if (
+ typeof column.sorter === 'object' &&
+ typeof column.sorter.multiple === 'number'
+ ) {
+ return column.sorter.multiple;
+ }
+ return false;
+}
+
+function getSortFunction(
+ sorter: ColumnType['sorter']
+): CompareFn | false {
+ if (typeof sorter === 'function') {
+ return sorter;
+ }
+ if (sorter && typeof sorter === 'object' && sorter.compare) {
+ return sorter.compare;
+ }
+ return false;
+}
+
+function nextSortDirection(
+ sortDirections: SortOrder[],
+ current: SortOrder | null
+) {
+ if (!current) {
+ return sortDirections[0];
+ }
+
+ return sortDirections[sortDirections.indexOf(current) + 1];
+}
+
+export interface SortState {
+ column: ColumnType;
+ key: Key;
+ sortOrder: SortOrder | null;
+ multiplePriority: number | false;
+}
+
+function collectSortStates(
+ columns: ColumnsType,
+ init: boolean,
+ pos?: string
+): SortState[] {
+ let sortStates: SortState[] = [];
+
+ function pushState(
+ column: ColumnsType[number],
+ columnPos: string
+ ) {
+ sortStates.push({
+ column,
+ key: getColumnKey(column, columnPos),
+ multiplePriority: getMultiplePriority(column),
+ sortOrder: column.sortOrder!,
+ });
+ }
+
+ (columns || []).forEach((column, index) => {
+ const columnPos = getColumnPos(index, pos);
+
+ if ((column as ColumnGroupType).children) {
+ if ('sortOrder' in column) {
+ // Controlled
+ pushState(column, columnPos);
+ }
+ sortStates = [
+ ...sortStates,
+ ...collectSortStates(
+ (column as ColumnGroupType).children,
+ init,
+ columnPos
+ ),
+ ];
+ } else if (column.sorter) {
+ if ('sortOrder' in column) {
+ // Controlled
+ pushState(column, columnPos);
+ } else if (init && column.defaultSortOrder) {
+ // Default sorter
+ sortStates.push({
+ column,
+ key: getColumnKey(column, columnPos),
+ multiplePriority: getMultiplePriority(column),
+ sortOrder: column.defaultSortOrder!,
+ });
+ }
+ }
+ });
+
+ return sortStates;
+}
+
+function injectSorter(
+ cancelSortText: string,
+ columns: ColumnsType,
+ sorterStates: SortState[],
+ triggerSorter: (sorterSates: SortState) => void,
+ defaultSortDirections: SortOrder[],
+ triggerAscText: string,
+ triggerDescText: string,
+ tableShowSorterTooltip?: boolean | TooltipProps,
+ pos?: string
+): ColumnsType {
+ return (columns || []).map((column, index) => {
+ const columnPos = getColumnPos(index, pos);
+ let newColumn: ColumnsType[number] = column;
+
+ if (newColumn.sorter) {
+ const sortDirections: SortOrder[] =
+ newColumn.sortDirections || defaultSortDirections;
+ const showSorterTooltip =
+ newColumn.showSorterTooltip === undefined
+ ? tableShowSorterTooltip
+ : newColumn.showSorterTooltip;
+ const columnKey = getColumnKey(newColumn, columnPos);
+ const sorterState = sorterStates.find(
+ ({ key }) => key === columnKey
+ );
+ const sorterOrder = sorterState ? sorterState.sortOrder : null;
+ const nextSortOrder = nextSortDirection(
+ sortDirections,
+ sorterOrder
+ );
+ const upNode: React.ReactNode = sortDirections.includes(ASCEND) && (
+
+ );
+ const downNode: React.ReactNode = sortDirections.includes(
+ DESCEND
+ ) && (
+
+ );
+ let sortTip: string | undefined = cancelSortText;
+ if (nextSortOrder === DESCEND) {
+ sortTip = triggerDescText;
+ } else if (nextSortOrder === ASCEND) {
+ sortTip = triggerAscText;
+ }
+ const tooltipProps: TooltipProps =
+ typeof showSorterTooltip === 'object'
+ ? showSorterTooltip
+ : { content: sortTip };
+ newColumn = {
+ ...newColumn,
+ classNames: mergeClasses([
+ newColumn.classNames,
+ { [styles.tableColumnSort]: sorterOrder },
+ ]),
+ title: (renderProps: ColumnTitleProps) => {
+ const renderSortTitle = (
+
+
+ {renderColumnTitle(column.title, renderProps)}
+
+
+
+ {upNode}
+ {downNode}
+
+
+
+ );
+ return showSorterTooltip ? (
+
+ {renderSortTitle}
+
+ ) : (
+ renderSortTitle
+ );
+ },
+ onHeaderCell: (col) => {
+ const cell: React.HTMLAttributes =
+ (column.onHeaderCell && column.onHeaderCell(col)) || {};
+ const originOnClick = cell.onClick;
+ const originOKeyDown = cell.onKeyDown;
+ cell.onClick = (event: React.MouseEvent) => {
+ triggerSorter({
+ column,
+ key: columnKey,
+ sortOrder: nextSortOrder,
+ multiplePriority: getMultiplePriority(column),
+ });
+ originOnClick?.(event);
+ };
+ cell.onKeyDown = (
+ event: React.KeyboardEvent
+ ) => {
+ if (event.key === eventKeys.ENTER) {
+ triggerSorter({
+ column,
+ key: columnKey,
+ sortOrder: nextSortOrder,
+ multiplePriority: getMultiplePriority(column),
+ });
+ originOKeyDown?.(event);
+ }
+ };
+
+ // Inform the screen-reader so it can tell the visually impaired user which column is sorted
+ if (sorterOrder) {
+ if (sorterOrder === 'ascend') {
+ cell['aria-sort'] = 'ascending';
+ } else {
+ cell['aria-sort'] = 'descending';
+ }
+ }
+
+ cell.className = mergeClasses([
+ cell.className,
+ styles.tableColumnHasSorters,
+ ]);
+ cell.tabIndex = 0;
+
+ return cell;
+ },
+ };
+ }
+
+ if ('children' in newColumn) {
+ newColumn = {
+ ...newColumn,
+ children: injectSorter(
+ cancelSortText,
+ newColumn.children,
+ sorterStates,
+ triggerSorter,
+ defaultSortDirections,
+ triggerAscText,
+ triggerDescText,
+ tableShowSorterTooltip,
+ columnPos
+ ),
+ };
+ }
+
+ return newColumn;
+ });
+}
+
+function stateToInfo(sorterStates: SortState) {
+ const { column, sortOrder } = sorterStates;
+ return {
+ column,
+ order: sortOrder,
+ field: column.dataIndex,
+ columnKey: column.key,
+ };
+}
+
+function generateSorterInfo(
+ sorterStates: SortState[]
+): SorterResult | SorterResult[] {
+ const list = sorterStates
+ .filter(({ sortOrder }) => sortOrder)
+ .map(stateToInfo);
+
+ // =========== Legacy compatible support ===========
+ if (list.length === 0 && sorterStates.length) {
+ return {
+ ...stateToInfo(sorterStates[sorterStates.length - 1]),
+ column: undefined,
+ };
+ }
+
+ if (list.length <= 1) {
+ return list[0] || {};
+ }
+
+ return list;
+}
+
+export function getSortData(
+ data: readonly RecordType[],
+ sortStates: SortState[],
+ childrenColumnName: string
+): RecordType[] {
+ const innerSorterStates = sortStates
+ .slice()
+ .sort(
+ (a, b) =>
+ (b.multiplePriority as number) - (a.multiplePriority as number)
+ );
+
+ const cloneData = data.slice();
+
+ const runningSorters = innerSorterStates.filter(
+ ({ column: { sorter }, sortOrder }) =>
+ getSortFunction(sorter) && sortOrder
+ );
+
+ // Skip if no sorter needed
+ if (!runningSorters.length) {
+ return cloneData;
+ }
+
+ return cloneData
+ .sort((record1, record2) => {
+ for (let i = 0; i < runningSorters.length; i += 1) {
+ const sorterState = runningSorters[i];
+ const {
+ column: { sorter },
+ sortOrder,
+ } = sorterState;
+
+ const compareFn = getSortFunction(sorter);
+
+ if (compareFn && sortOrder) {
+ const compareResult = compareFn(
+ record1,
+ record2,
+ sortOrder
+ );
+
+ if (compareResult !== 0) {
+ return sortOrder === ASCEND
+ ? compareResult
+ : -compareResult;
+ }
+ }
+ }
+
+ return 0;
+ })
+ .map((record) => {
+ const subRecords = (record as any)[childrenColumnName];
+ if (subRecords) {
+ return {
+ ...record,
+ [childrenColumnName]: getSortData(
+ subRecords,
+ sortStates,
+ childrenColumnName
+ ),
+ };
+ }
+ return record;
+ });
+}
+
+interface SorterConfig {
+ cancelSortText: string;
+ mergedColumns: ColumnsType;
+ onSorterChange: (
+ sorterResult: SorterResult | SorterResult[],
+ sortStates: SortState[]
+ ) => void;
+ sortDirections: SortOrder[];
+ triggerAscText: string;
+ triggerDescText: string;
+ showSorterTooltip?: boolean | TooltipProps;
+}
+
+export default function useFilterSorter({
+ cancelSortText,
+ mergedColumns,
+ onSorterChange,
+ sortDirections,
+ triggerAscText,
+ triggerDescText,
+ showSorterTooltip,
+}: SorterConfig): [
+ TransformColumns,
+ SortState[],
+ ColumnTitleProps,
+ () => SorterResult | SorterResult[]
+] {
+ const [sortStates, setSortStates] = useState[]>(
+ collectSortStates(mergedColumns, true)
+ );
+
+ const mergedSorterStates = useMemo(() => {
+ let validate = true;
+ const collectedStates = collectSortStates(mergedColumns, false);
+
+ // Return if not controlled
+ if (!collectedStates.length) {
+ return sortStates;
+ }
+
+ const validateStates: SortState[] = [];
+
+ function patchStates(state: SortState) {
+ if (validate) {
+ validateStates.push(state);
+ } else {
+ validateStates.push({
+ ...state,
+ sortOrder: null,
+ });
+ }
+ }
+
+ let multipleMode: boolean | null = null;
+ collectedStates.forEach((state) => {
+ if (multipleMode === null) {
+ patchStates(state);
+
+ if (state.sortOrder) {
+ if (state.multiplePriority === false) {
+ validate = false;
+ } else {
+ multipleMode = true;
+ }
+ }
+ } else if (multipleMode && state.multiplePriority !== false) {
+ patchStates(state);
+ } else {
+ validate = false;
+ patchStates(state);
+ }
+ });
+
+ return validateStates;
+ }, [mergedColumns, sortStates]);
+
+ // Get render columns title required props
+ const columnTitleSorterProps = useMemo<
+ ColumnTitleProps
+ >((): any => {
+ const sortColumns = mergedSorterStates.map(({ column, sortOrder }) => ({
+ column,
+ order: sortOrder,
+ }));
+
+ return {
+ sortColumns,
+ // Legacy
+ sortColumn: sortColumns[0] && sortColumns[0].column,
+ sortOrder: sortColumns[0] && sortColumns[0].order,
+ };
+ }, [mergedSorterStates]);
+
+ function triggerSorter(sortState: SortState) {
+ let newSorterStates;
+
+ if (
+ sortState.multiplePriority === false ||
+ !mergedSorterStates.length ||
+ mergedSorterStates[0].multiplePriority === false
+ ) {
+ newSorterStates = [sortState];
+ } else {
+ newSorterStates = [
+ ...mergedSorterStates.filter(
+ ({ key }) => key !== sortState.key
+ ),
+ sortState,
+ ];
+ }
+
+ setSortStates(newSorterStates);
+ onSorterChange(generateSorterInfo(newSorterStates), newSorterStates);
+ }
+
+ const transformColumns = (innerColumns: ColumnsType) =>
+ injectSorter(
+ cancelSortText,
+ innerColumns,
+ mergedSorterStates,
+ triggerSorter,
+ sortDirections,
+ triggerAscText,
+ triggerDescText,
+ showSorterTooltip
+ );
+
+ const getSorters = () => generateSorterInfo(mergedSorterStates);
+
+ return [
+ transformColumns,
+ mergedSorterStates,
+ columnTitleSorterProps,
+ getSorters,
+ ];
+}
diff --git a/src/components/Table/Hooks/useTitleColumns.tsx b/src/components/Table/Hooks/useTitleColumns.tsx
new file mode 100644
index 000000000..ac0e4189d
--- /dev/null
+++ b/src/components/Table/Hooks/useTitleColumns.tsx
@@ -0,0 +1,39 @@
+import { useCallback } from 'react';
+import type {
+ TransformColumns,
+ ColumnTitleProps,
+ ColumnsType,
+} from '../Table.types';
+import { renderColumnTitle } from '../utlities';
+
+function fillTitle(
+ columns: ColumnsType,
+ columnTitleProps: ColumnTitleProps
+) {
+ return columns.map((column) => {
+ const cloneColumn = { ...column };
+
+ cloneColumn.title = renderColumnTitle(column.title, columnTitleProps);
+
+ if ('children' in cloneColumn) {
+ cloneColumn.children = fillTitle(
+ cloneColumn.children,
+ columnTitleProps
+ );
+ }
+
+ return cloneColumn;
+ });
+}
+
+export default function useTitleColumns(
+ columnTitleProps: ColumnTitleProps
+): [TransformColumns] {
+ const filledColumns = useCallback(
+ (columns: ColumnsType) =>
+ fillTitle(columns, columnTitleProps),
+ [columnTitleProps]
+ );
+
+ return [filledColumns];
+}
diff --git a/src/components/Table/Internal/Body/Body.types.ts b/src/components/Table/Internal/Body/Body.types.ts
new file mode 100644
index 000000000..0963aa201
--- /dev/null
+++ b/src/components/Table/Internal/Body/Body.types.ts
@@ -0,0 +1,55 @@
+import React from 'react';
+import type {
+ CustomizeComponent,
+ GetComponentProps,
+ Key,
+ GetRowKey,
+} from '../OcTable.types';
+
+export interface MeasureRowProps extends Omit {
+ columnsKey: React.Key[];
+}
+
+export interface MeasureCellProps {
+ columnKey: React.Key;
+ onColumnResize: (key: React.Key, width: number) => void;
+}
+
+export interface ExpandedRowProps {
+ component: CustomizeComponent;
+ cellComponent: CustomizeComponent;
+ classNames: string;
+ expanded: boolean;
+ children: React.ReactNode;
+ colSpan: number;
+ isEmpty: boolean;
+}
+
+export interface BodyRowProps {
+ record: RecordType;
+ index: number;
+ renderIndex: number;
+ classNames?: string;
+ style?: React.CSSProperties;
+ recordKey: Key;
+ expandedKeys: Set;
+ rowComponent: CustomizeComponent;
+ cellComponent: CustomizeComponent;
+ onRow: GetComponentProps;
+ rowExpandable: (record: RecordType) => boolean;
+ indent?: number;
+ rowKey: React.Key;
+ getRowKey: GetRowKey;
+ childrenColumnName: string;
+}
+
+export interface BodyProps {
+ data: readonly RecordType[];
+ getRowKey: GetRowKey;
+ measureColumnWidth: boolean;
+ expandedKeys: Set;
+ onRow: GetComponentProps;
+ rowExpandable: (record: RecordType) => boolean;
+ emptyNode: React.ReactNode;
+ childrenColumnName: string;
+}
diff --git a/src/components/Table/Internal/Body/BodyRow.tsx b/src/components/Table/Internal/Body/BodyRow.tsx
new file mode 100644
index 000000000..30b61f97a
--- /dev/null
+++ b/src/components/Table/Internal/Body/BodyRow.tsx
@@ -0,0 +1,209 @@
+import React, { useContext, useEffect, useRef, useState } from 'react';
+import { mergeClasses } from '../../../../shared/utilities';
+import Cell from '../Cell';
+import TableContext from '../Context/TableContext';
+import BodyContext from '../Context/BodyContext';
+import { getColumnsKey } from '../Utilities/valueUtil';
+import type { ColumnType } from '../OcTable.types';
+import { BodyRowProps } from './Body.types';
+import ExpandedRow from './ExpandedRow';
+
+import styles from '../octable.module.scss';
+
+function BodyRow(
+ props: BodyRowProps
+) {
+ const {
+ classNames,
+ style,
+ record,
+ index,
+ renderIndex,
+ rowKey,
+ rowExpandable,
+ expandedKeys,
+ onRow,
+ indent = 0,
+ rowComponent: RowComponent,
+ cellComponent,
+ childrenColumnName,
+ } = props;
+ const { fixedInfoList } = useContext(TableContext);
+ const {
+ flattenColumns,
+ expandableType,
+ expandRowByClick,
+ onTriggerExpand,
+ rowClassName,
+ expandedRowClassName,
+ indentSize,
+ expandIcon,
+ expandedRowRender,
+ } = useContext(BodyContext);
+ const [expandRended, setExpandRended] = useState(false);
+
+ const expanded = expandedKeys && expandedKeys.has(props.recordKey);
+
+ useEffect(() => {
+ if (expanded) {
+ setExpandRended(true);
+ }
+ }, [expanded]);
+
+ const rowSupportExpand =
+ expandableType === 'row' && (!rowExpandable || rowExpandable(record));
+ // Only when row is not expandable and `children` exist in record
+ const nestExpandable = expandableType === 'nest';
+ const hasNestChildren: boolean =
+ childrenColumnName && record && (record as any)[childrenColumnName];
+ const mergedExpandable = rowSupportExpand || nestExpandable;
+
+ // ======================== Expandable =========================
+ const onExpandRef = useRef(onTriggerExpand);
+ onExpandRef.current = onTriggerExpand;
+
+ const onInternalTriggerExpand = (
+ ...args: Parameters
+ ) => {
+ onExpandRef.current(...args);
+ };
+
+ // =========================== onRow ===========================
+ let additionalProps: React.HTMLAttributes;
+ if (onRow) {
+ additionalProps = onRow(record, index);
+ }
+
+ const onClick: React.MouseEventHandler = (event, ...args) => {
+ if (expandRowByClick && mergedExpandable) {
+ onInternalTriggerExpand(record, event);
+ }
+
+ additionalProps?.onClick?.(event, ...args);
+ };
+
+ // ======================== Base tr row ========================
+ let computeRowClassName: string;
+ if (typeof rowClassName === 'string') {
+ computeRowClassName = rowClassName;
+ } else if (typeof rowClassName === 'function') {
+ computeRowClassName = rowClassName(record, index, indent);
+ }
+
+ const columnsKey = getColumnsKey(flattenColumns);
+ const baseRowNode = (
+
+ {flattenColumns.map((column: ColumnType, colIndex) => {
+ const {
+ render,
+ dataIndex,
+ classNames: columnClassName,
+ } = column;
+
+ const key = columnsKey[colIndex];
+ const fixedInfo = fixedInfoList[colIndex];
+
+ // ============= Used for nest expandable =============
+ let appendCellNode: React.ReactNode;
+ if (colIndex === 0 && nestExpandable) {
+ appendCellNode = (
+ <>
+
+ {expandIcon({
+ expanded,
+ expandable: hasNestChildren,
+ record,
+ onExpand: onInternalTriggerExpand,
+ })}
+ >
+ );
+ }
+
+ let additionalCellProps: React.HTMLAttributes;
+ if (column.onCell) {
+ additionalCellProps = column.onCell(record, index);
+ }
+
+ return (
+ |
+ );
+ })}
+
+ );
+
+ // ======================== Expand Row =========================
+ let expandRowNode: React.ReactElement;
+ if (rowSupportExpand && (expandRended || expanded)) {
+ const expandContent = expandedRowRender(
+ record,
+ index,
+ indent + 1,
+ expanded
+ );
+ const computedExpandedRowClassName =
+ expandedRowClassName && expandedRowClassName(record, index, indent);
+ expandRowNode = (
+
+ {expandContent}
+
+ );
+ }
+
+ return (
+ <>
+ {baseRowNode}
+ {expandRowNode}
+ >
+ );
+}
+
+BodyRow.displayName = 'BodyRow';
+
+export default BodyRow;
diff --git a/src/components/Table/Internal/Body/ExpandedRow.tsx b/src/components/Table/Internal/Body/ExpandedRow.tsx
new file mode 100644
index 000000000..bda60801c
--- /dev/null
+++ b/src/components/Table/Internal/Body/ExpandedRow.tsx
@@ -0,0 +1,69 @@
+import React, { useContext, useMemo } from 'react';
+import { ExpandedRowProps } from './Body.types';
+import Cell from '../Cell';
+import TableContext from '../Context/TableContext';
+import ExpandedRowContext from '../Context/ExpandedRowContext';
+
+import styles from '../octable.module.scss';
+
+function ExpandedRow({
+ children,
+ component: Component,
+ cellComponent,
+ classNames,
+ expanded,
+ colSpan,
+ isEmpty,
+}: ExpandedRowProps) {
+ const { scrollbarSize } = useContext(TableContext);
+ const { fixHeader, fixColumn, componentWidth, horizonScroll } =
+ useContext(ExpandedRowContext);
+
+ // Cache render node
+ return useMemo(() => {
+ let contentNode = children;
+
+ if (isEmpty ? horizonScroll : fixColumn) {
+ contentNode = (
+
+ {contentNode}
+
+ );
+ }
+
+ return (
+
+
+ {contentNode}
+ |
+
+ );
+ }, [
+ children,
+ Component,
+ classNames,
+ expanded,
+ colSpan,
+ isEmpty,
+ scrollbarSize,
+ componentWidth,
+ fixColumn,
+ fixHeader,
+ horizonScroll,
+ ]);
+}
+
+export default ExpandedRow;
diff --git a/src/components/Table/Internal/Body/MeasureCell.tsx b/src/components/Table/Internal/Body/MeasureCell.tsx
new file mode 100644
index 000000000..aca620293
--- /dev/null
+++ b/src/components/Table/Internal/Body/MeasureCell.tsx
@@ -0,0 +1,24 @@
+import React, { useEffect, useRef } from 'react';
+import { ResizeObserver } from '../../../../shared/ResizeObserver/ResizeObserver';
+import { MeasureCellProps } from './Body.types';
+
+export default function MeasureCell({
+ columnKey,
+ onColumnResize,
+}: MeasureCellProps) {
+ const cellRef = useRef();
+
+ useEffect(() => {
+ if (cellRef.current) {
+ onColumnResize(columnKey, cellRef.current.offsetWidth);
+ }
+ }, []);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/Table/Internal/Body/MeasureRow.tsx b/src/components/Table/Internal/Body/MeasureRow.tsx
new file mode 100644
index 000000000..a823b5747
--- /dev/null
+++ b/src/components/Table/Internal/Body/MeasureRow.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { ResizeObserver } from '../../../../shared/ResizeObserver/ResizeObserver';
+import MeasureCell from './MeasureCell';
+import { MeasureRowProps } from './Body.types';
+
+export default function MeasureRow({
+ columnsKey,
+ onColumnResize,
+}: MeasureRowProps) {
+ return (
+
+ {
+ infoList.forEach(({ data: columnKey, size }) => {
+ onColumnResize(columnKey, size.offsetWidth);
+ });
+ }}
+ >
+ {columnsKey.map((columnKey) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/Table/Internal/Body/index.tsx b/src/components/Table/Internal/Body/index.tsx
new file mode 100644
index 000000000..e69c8f1e8
--- /dev/null
+++ b/src/components/Table/Internal/Body/index.tsx
@@ -0,0 +1,154 @@
+import React, {
+ memo,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import TableContext from '../Context/TableContext';
+import ExpandedRow from './ExpandedRow';
+import BodyContext from '../Context/BodyContext';
+import { getColumnsKey } from '../Utilities/valueUtil';
+import ResizeContext from '../Context/ResizeContext';
+import BodyRow from './BodyRow';
+import useFlattenRecords from '../Hooks/useFlattenRecords';
+import HoverContext from '../Context/HoverContext';
+import type { PerfRecord } from '../Context/PerfContext';
+import PerfContext from '../Context/PerfContext';
+import MeasureRow from './MeasureRow';
+import { BodyProps } from './Body.types';
+
+import styles from '../octable.module.scss';
+
+function Body({
+ data,
+ getRowKey,
+ measureColumnWidth,
+ expandedKeys,
+ onRow,
+ rowExpandable,
+ emptyNode,
+ childrenColumnName,
+}: BodyProps) {
+ const { onColumnResize } = useContext(ResizeContext);
+ const { getComponent } = useContext(TableContext);
+ const { flattenColumns } = useContext(BodyContext);
+
+ const flattenData: { record: RecordType; indent: number; index: number }[] =
+ useFlattenRecords(
+ data,
+ childrenColumnName,
+ expandedKeys,
+ getRowKey
+ );
+
+ // =================== Performance ====================
+ const perfRef = useRef({
+ renderWithProps: false,
+ });
+
+ // ====================== Hover =======================
+ const [startRow, setStartRow] = useState(-1);
+ const [endRow, setEndRow] = useState(-1);
+
+ const onHover = useCallback((start: number, end: number) => {
+ setStartRow(start);
+ setEndRow(end);
+ }, []);
+
+ const hoverContext = useMemo(
+ () => ({ startRow, endRow, onHover }),
+ [onHover, startRow, endRow]
+ );
+
+ // ====================== Render ======================
+ const bodyNode = useMemo(() => {
+ const WrapperComponent = getComponent(['body', 'wrapper'], 'tbody');
+ const trComponent = getComponent(['body', 'row'], 'tr');
+ const tdComponent = getComponent(['body', 'cell'], 'td');
+
+ let rows: React.ReactNode;
+ if (data.length) {
+ rows = flattenData.map((item, idx) => {
+ const { record, indent, index: renderIndex } = item;
+
+ const key = getRowKey(record, idx);
+
+ return (
+
+ );
+ });
+ } else {
+ rows = (
+
+ {emptyNode}
+
+ );
+ }
+
+ const columnsKey = getColumnsKey(flattenColumns);
+
+ return (
+
+ {/* Measure body column width with additional hidden col */}
+ {measureColumnWidth && (
+
+ )}
+
+ {rows}
+
+ );
+ }, [
+ data,
+ onRow,
+ measureColumnWidth,
+ expandedKeys,
+ getRowKey,
+ getComponent,
+ emptyNode,
+ flattenColumns,
+ childrenColumnName,
+ onColumnResize,
+ rowExpandable,
+ flattenData,
+ ]);
+
+ return (
+
+
+ {bodyNode}
+
+
+ );
+}
+
+const MemoBody = memo(Body);
+MemoBody.displayName = 'Body';
+
+export default MemoBody;
diff --git a/src/components/Table/Internal/Cell/Cell.types.ts b/src/components/Table/Internal/Cell/Cell.types.ts
new file mode 100644
index 000000000..9b2253120
--- /dev/null
+++ b/src/components/Table/Internal/Cell/Cell.types.ts
@@ -0,0 +1,55 @@
+import type {
+ DataIndex,
+ ColumnType,
+ CustomizeComponent,
+ DefaultRecordType,
+ AlignType,
+ CellEllipsisType,
+} from '../OcTable.types';
+import type { HoverContextProps } from '../Context/HoverContext';
+
+export interface InternalCellProps
+ extends Pick {
+ classNames?: string;
+ record?: RecordType;
+ /** `column` index is the real show rowIndex */
+ index?: number;
+ /** the index of the record. For the render(value, record, renderIndex) */
+ renderIndex?: number;
+ dataIndex?: DataIndex;
+ render?: ColumnType['render'];
+ component?: CustomizeComponent;
+ children?: React.ReactNode;
+ colSpan?: number;
+ rowSpan?: number;
+ ellipsis?: CellEllipsisType;
+ align?: AlignType;
+
+ shouldCellUpdate?: (record: RecordType, prevRecord: RecordType) => boolean;
+
+ // Fixed
+ fixLeft?: number | false;
+ fixRight?: number | false;
+ firstFixLeft?: boolean;
+ lastFixLeft?: boolean;
+ firstFixRight?: boolean;
+ lastFixRight?: boolean;
+
+ // ====================== Private Props ======================
+ /** @private Used for `expandable` with nest tree */
+ appendNode?: React.ReactNode;
+ additionalProps?: React.TdHTMLAttributes;
+ /** @private Fixed for user use `shouldCellUpdate` which block the render */
+ expanded?: boolean;
+
+ rowType?: 'header' | 'body' | 'footer';
+
+ isSticky?: boolean;
+
+ hovering?: boolean;
+}
+
+export type CellProps = Omit<
+ InternalCellProps,
+ keyof HoverContextProps
+>;
diff --git a/src/components/Table/Internal/Cell/index.tsx b/src/components/Table/Internal/Cell/index.tsx
new file mode 100644
index 000000000..b43ccd207
--- /dev/null
+++ b/src/components/Table/Internal/Cell/index.tsx
@@ -0,0 +1,314 @@
+import React, { forwardRef, memo, Ref, useContext, useMemo } from 'react';
+import { mergeClasses } from '../../../../shared/utilities';
+import shallowEqual from 'shallowequal';
+import type {
+ RenderedCell,
+ CellType,
+ DefaultRecordType,
+ CellEllipsisType,
+} from '../OcTable.types';
+import { CellProps, InternalCellProps } from './Cell.types';
+import { getPathValue, validateValue } from '../Utilities/valueUtil';
+import StickyContext from '../Context/StickyContext';
+import HoverContext from '../Context/HoverContext';
+import PerfContext from '../Context/PerfContext';
+
+import styles from '../octable.module.scss';
+
+/** Check if cell is in hover range */
+function inHoverRange(
+ cellStartRow: number,
+ cellRowSpan: number,
+ startRow: number,
+ endRow: number
+) {
+ const cellEndRow = cellStartRow + cellRowSpan - 1;
+ return cellStartRow <= endRow && cellEndRow >= startRow;
+}
+
+function isRenderCell(
+ data: React.ReactNode | RenderedCell
+): data is RenderedCell {
+ return (
+ data &&
+ typeof data === 'object' &&
+ !Array.isArray(data) &&
+ !React.isValidElement(data)
+ );
+}
+
+const getTitleFromCellRenderChildren = ({
+ ellipsis,
+ rowType,
+ children,
+}: Pick, 'ellipsis' | 'rowType' | 'children'>) => {
+ let title: string;
+ const ellipsisConfig: CellEllipsisType =
+ ellipsis === true ? { showTitle: true } : ellipsis;
+ if (ellipsisConfig && (ellipsisConfig.showTitle || rowType === 'header')) {
+ if (typeof children === 'string' || typeof children === 'number') {
+ title = children.toString();
+ } else if (
+ React.isValidElement(children) &&
+ typeof children.props.children === 'string'
+ ) {
+ title = children.props.children;
+ }
+ }
+ return title;
+};
+
+function Cell(
+ {
+ classNames,
+ record,
+ index,
+ renderIndex,
+ dataIndex,
+ render,
+ children,
+ component: Component = 'td',
+ colSpan,
+ rowSpan, // This is already merged on WrapperCell
+ fixLeft,
+ fixRight,
+ firstFixLeft,
+ lastFixLeft,
+ firstFixRight,
+ lastFixRight,
+ appendNode,
+ additionalProps = {},
+ ellipsis,
+ align,
+ rowType,
+ isSticky,
+ hovering,
+ onHover,
+ }: InternalCellProps,
+ ref: React.Ref
+): React.ReactElement {
+ const perfRecord = useContext(PerfContext);
+ const supportSticky = useContext(StickyContext);
+
+ // ==================== Child Node ====================
+ const [childNode, legacyCellProps] = useMemo<
+ [React.ReactNode, CellType] | [React.ReactNode]
+ >(() => {
+ if (validateValue(children)) {
+ return [children];
+ }
+
+ const value = getPathValue<
+ Record | React.ReactNode,
+ RecordType
+ >(record, dataIndex);
+
+ // Customize render node
+ let returnChildNode = value;
+ let returnCellProps: CellType | undefined = undefined;
+
+ if (render) {
+ const renderData = render(value, record, renderIndex);
+
+ if (isRenderCell(renderData)) {
+ returnChildNode = renderData.children;
+ returnCellProps = renderData.props;
+ perfRecord.renderWithProps = true;
+ } else {
+ returnChildNode = renderData;
+ }
+ }
+
+ return [returnChildNode, returnCellProps];
+ }, [
+ /* eslint-disable react-hooks/exhaustive-deps */
+ // Always re-render if `renderWithProps`
+ perfRecord.renderWithProps ? Math.random() : 0,
+ /* eslint-enable */
+ children,
+ dataIndex,
+ perfRecord,
+ record,
+ render,
+ renderIndex,
+ ]);
+
+ let mergedChildNode = childNode;
+
+ // Not crash if final `childNode` is not validate ReactNode
+ if (
+ typeof mergedChildNode === 'object' &&
+ !Array.isArray(mergedChildNode) &&
+ !React.isValidElement(mergedChildNode)
+ ) {
+ mergedChildNode = null;
+ }
+
+ if (ellipsis && (lastFixLeft || firstFixRight)) {
+ mergedChildNode = (
+ {mergedChildNode}
+ );
+ }
+
+ const {
+ colSpan: cellColSpan,
+ rowSpan: cellRowSpan,
+ style: cellStyle,
+ classNames: cellClassName,
+ ...restCellProps
+ } = legacyCellProps || {};
+ const mergedColSpan =
+ (cellColSpan !== undefined ? cellColSpan : colSpan) ?? 1;
+ const mergedRowSpan =
+ (cellRowSpan !== undefined ? cellRowSpan : rowSpan) ?? 1;
+
+ if (mergedColSpan === 0 || mergedRowSpan === 0) {
+ return null;
+ }
+
+ // ====================== Fixed =======================
+ const fixedStyle: React.CSSProperties = {};
+ const isFixLeft = typeof fixLeft === 'number' && supportSticky;
+ const isFixRight = typeof fixRight === 'number' && supportSticky;
+
+ if (isFixLeft) {
+ fixedStyle.position = 'sticky';
+ fixedStyle.left = fixLeft as number;
+ }
+ if (isFixRight) {
+ fixedStyle.position = 'sticky';
+ fixedStyle.right = fixRight as number;
+ }
+
+ // ====================== Align =======================
+ const alignStyle: React.CSSProperties = {};
+ if (align) {
+ alignStyle.textAlign = align;
+ }
+
+ // ====================== Hover =======================
+ const onMouseEnter: React.MouseEventHandler = (
+ event
+ ) => {
+ if (record) {
+ onHover(index, index + mergedRowSpan - 1);
+ }
+
+ additionalProps?.onMouseEnter?.(event);
+ };
+
+ const onMouseLeave: React.MouseEventHandler = (
+ event
+ ) => {
+ if (record) {
+ onHover(-1, -1);
+ }
+
+ additionalProps?.onMouseLeave?.(event);
+ };
+
+ // ====================== Render ======================
+ const title = getTitleFromCellRenderChildren({
+ rowType,
+ ellipsis,
+ children: childNode,
+ });
+
+ const componentProps: React.TdHTMLAttributes & {
+ ref: Ref;
+ } = {
+ title,
+ ...restCellProps,
+ ...additionalProps,
+ colSpan: mergedColSpan !== 1 ? mergedColSpan : null,
+ rowSpan: mergedRowSpan !== 1 ? mergedRowSpan : null,
+ className: mergeClasses?.([
+ styles.tableCell,
+ classNames,
+ { [styles.tableCellFixLeft]: isFixLeft && supportSticky },
+ { [styles.tableCellFixLeftFirst]: firstFixLeft && supportSticky },
+ { [styles.tableCellFixLeftLast]: lastFixLeft && supportSticky },
+ { [styles.tableCellFixRight]: isFixRight && supportSticky },
+ { [styles.tableCellFixRightFirst]: firstFixRight && supportSticky },
+ { [styles.tableCellFixRightLast]: lastFixRight && supportSticky },
+ { [styles.tableCellEllipsis]: ellipsis },
+ { ['table-cell-with-append']: appendNode },
+ {
+ [styles.tableCellFixSticky]:
+ (isFixLeft || isFixRight) && isSticky && supportSticky,
+ },
+ { [styles.tableCellRowHover]: !legacyCellProps && hovering },
+ additionalProps.className,
+ cellClassName,
+ ]),
+ style: {
+ ...additionalProps.style,
+ ...alignStyle,
+ ...fixedStyle,
+ ...cellStyle,
+ },
+ onMouseEnter,
+ onMouseLeave,
+ ref: ref,
+ };
+
+ return (
+
+ {appendNode}
+ {mergedChildNode}
+
+ );
+}
+
+const RefCell = forwardRef>(Cell);
+RefCell.displayName = 'Cell';
+
+const comparePropList: (keyof InternalCellProps)[] = [
+ 'expanded',
+ 'classNames',
+ 'hovering',
+];
+
+const MemoCell = memo(
+ RefCell,
+ (prev: InternalCellProps, next: InternalCellProps) => {
+ if (next.shouldCellUpdate) {
+ return (
+ // Additional handle of expanded logic
+ comparePropList.every(
+ (propName) => prev[propName] === next[propName]
+ ) &&
+ // User control update logic
+ !next.shouldCellUpdate(next.record, prev.record)
+ );
+ }
+
+ return shallowEqual(prev, next);
+ }
+);
+
+/** Inject hover data here, we still wish MemoCell keep simple `shouldCellUpdate` logic */
+const WrappedCell = forwardRef((props: CellProps, ref: Ref) => {
+ const { onHover, startRow, endRow } = useContext(HoverContext);
+ const { index, additionalProps = {}, colSpan, rowSpan } = props;
+ const { colSpan: cellColSpan, rowSpan: cellRowSpan } = additionalProps;
+
+ const mergedColSpan = colSpan ?? cellColSpan;
+ const mergedRowSpan = rowSpan ?? cellRowSpan;
+
+ const hovering = inHoverRange(index, mergedRowSpan || 1, startRow, endRow);
+
+ return (
+
+ );
+});
+WrappedCell.displayName = 'WrappedCell';
+
+export default WrappedCell;
diff --git a/src/components/Table/Internal/ColGroup.tsx b/src/components/Table/Internal/ColGroup.tsx
new file mode 100644
index 000000000..a2b20a3d3
--- /dev/null
+++ b/src/components/Table/Internal/ColGroup.tsx
@@ -0,0 +1,34 @@
+import React, { ReactElement } from 'react';
+import { ColGroupProps } from './OcTable.types';
+import { INTERNAL_COL_DEFINE } from './constant';
+
+function ColGroup({
+ colWidths,
+ columns,
+ columCount,
+}: ColGroupProps) {
+ const cols: ReactElement[] = [];
+ const len = columCount || columns.length;
+
+ // Only insert col with width & additional props
+ // Skip if rest col do not have any useful info
+ let mustInsert = false;
+ for (let i = len - 1; i >= 0; i -= 1) {
+ const width = colWidths[i];
+ const column = columns && columns[i];
+ const additionalProps = column && (column as any)[INTERNAL_COL_DEFINE];
+
+ if (width || additionalProps || mustInsert) {
+ const { columnType, ...restAdditionalProps } =
+ additionalProps || {};
+ cols.unshift(
+
+ );
+ mustInsert = true;
+ }
+ }
+
+ return {cols} ;
+}
+
+export default ColGroup;
diff --git a/src/components/Table/Internal/Column.tsx b/src/components/Table/Internal/Column.tsx
new file mode 100644
index 000000000..5539a628d
--- /dev/null
+++ b/src/components/Table/Internal/Column.tsx
@@ -0,0 +1,16 @@
+import type { ColumnType } from './OcTable.types';
+
+export interface ColumnProps extends ColumnType {
+ children?: null;
+}
+
+/**
+ * This is a syntactic sugar for `columns` prop.
+ * So HOC will not work on this.
+ */
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+function Column(_: ColumnProps): any {
+ return null;
+}
+
+export default Column;
diff --git a/src/components/Table/Internal/ColumnGroup.tsx b/src/components/Table/Internal/ColumnGroup.tsx
new file mode 100644
index 000000000..b5e1c208c
--- /dev/null
+++ b/src/components/Table/Internal/ColumnGroup.tsx
@@ -0,0 +1,21 @@
+import { ReactElement } from 'react';
+import type { ColumnProps } from './Column';
+import type { ColumnType } from './OcTable.types';
+
+export interface ColumnGroupProps
+ extends Omit, 'children'> {
+ children:
+ | ReactElement>
+ | readonly ReactElement>[];
+}
+
+/**
+ * This is a syntactic sugar for `columns` prop.
+ * So HOC will not work on this.
+ */
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+function ColumnGroup(_: ColumnGroupProps): any {
+ return null;
+}
+
+export default ColumnGroup;
diff --git a/src/components/Table/Internal/Context/BodyContext.tsx b/src/components/Table/Internal/Context/BodyContext.tsx
new file mode 100644
index 000000000..df36b2d03
--- /dev/null
+++ b/src/components/Table/Internal/Context/BodyContext.tsx
@@ -0,0 +1,33 @@
+import { createContext } from 'react';
+import type {
+ ColumnType,
+ DefaultRecordType,
+ ColumnsType,
+ TableLayout,
+ RenderExpandIcon,
+ ExpandableType,
+ RowClassName,
+ TriggerEventHandler,
+ ExpandedRowRender,
+} from '../OcTable.types';
+
+export interface BodyContextProps {
+ rowClassName: string | RowClassName;
+ expandedRowClassName: RowClassName;
+
+ columns: ColumnsType;
+ flattenColumns: readonly ColumnType[];
+
+ tableLayout: TableLayout;
+
+ indentSize: number;
+ expandableType: ExpandableType;
+ expandRowByClick: boolean;
+ expandedRowRender: ExpandedRowRender;
+ expandIcon: RenderExpandIcon;
+ onTriggerExpand: TriggerEventHandler;
+}
+
+const BodyContext = createContext(null);
+
+export default BodyContext;
diff --git a/src/components/Table/Internal/Context/ExpandedRowContext.tsx b/src/components/Table/Internal/Context/ExpandedRowContext.tsx
new file mode 100644
index 000000000..fde3b294c
--- /dev/null
+++ b/src/components/Table/Internal/Context/ExpandedRowContext.tsx
@@ -0,0 +1,12 @@
+import { createContext } from 'react';
+
+export interface ExpandedRowProps {
+ componentWidth: number;
+ fixHeader: boolean;
+ fixColumn: boolean;
+ horizonScroll: boolean;
+}
+
+const ExpandedRowContext = createContext(null);
+
+export default ExpandedRowContext;
diff --git a/src/components/Table/Internal/Context/HoverContext.tsx b/src/components/Table/Internal/Context/HoverContext.tsx
new file mode 100644
index 000000000..4ccb13e09
--- /dev/null
+++ b/src/components/Table/Internal/Context/HoverContext.tsx
@@ -0,0 +1,11 @@
+import { createContext } from 'react';
+
+export interface HoverContextProps {
+ startRow: number;
+ endRow: number;
+ onHover: (start: number, end: number) => void;
+}
+
+const HoverContext = createContext({} as any);
+
+export default HoverContext;
diff --git a/src/components/Table/Internal/Context/PerfContext.tsx b/src/components/Table/Internal/Context/PerfContext.tsx
new file mode 100644
index 000000000..1533f5286
--- /dev/null
+++ b/src/components/Table/Internal/Context/PerfContext.tsx
@@ -0,0 +1,18 @@
+import { createContext } from 'react';
+
+export interface PerfRecord {
+ /**
+ * Ensure Cells always rerender if set to true,
+ * Use cautiously as this will effect performance
+ * rerender is triggered by certain events:
+ * onMouseEnter, onMouseLeave, onScroll
+ * @default false
+ */
+ renderWithProps: boolean;
+}
+
+const PerfContext = createContext({
+ renderWithProps: false,
+});
+
+export default PerfContext;
diff --git a/src/components/Table/Internal/Context/ResizeContext.tsx b/src/components/Table/Internal/Context/ResizeContext.tsx
new file mode 100644
index 000000000..86591cc8c
--- /dev/null
+++ b/src/components/Table/Internal/Context/ResizeContext.tsx
@@ -0,0 +1,9 @@
+import { createContext, Key } from 'react';
+
+interface ResizeContextProps {
+ onColumnResize: (columnKey: Key, width: number) => void;
+}
+
+const ResizeContext = createContext(null);
+
+export default ResizeContext;
diff --git a/src/components/Table/Internal/Context/SizeContext.tsx b/src/components/Table/Internal/Context/SizeContext.tsx
new file mode 100644
index 000000000..b0442608a
--- /dev/null
+++ b/src/components/Table/Internal/Context/SizeContext.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+
+export type SizeType = 'small' | 'medium' | 'large' | undefined;
+
+const SizeContext = React.createContext(undefined);
+
+export interface SizeContextProps {
+ size?: SizeType;
+ children?: React.ReactNode;
+}
+
+export const SizeContextProvider: React.FC = ({
+ children,
+ size,
+}) => (
+
+ {(originSize) => (
+
+ {children}
+
+ )}
+
+);
+
+export default SizeContext;
diff --git a/src/components/Table/Internal/Context/StickyContext.tsx b/src/components/Table/Internal/Context/StickyContext.tsx
new file mode 100644
index 000000000..aaeb5ef76
--- /dev/null
+++ b/src/components/Table/Internal/Context/StickyContext.tsx
@@ -0,0 +1,6 @@
+import { createContext } from 'react';
+
+// Tell cell that browser support sticky
+const StickyContext = createContext(false);
+
+export default StickyContext;
diff --git a/src/components/Table/Internal/Context/TableContext.tsx b/src/components/Table/Internal/Context/TableContext.tsx
new file mode 100644
index 000000000..999780d89
--- /dev/null
+++ b/src/components/Table/Internal/Context/TableContext.tsx
@@ -0,0 +1,15 @@
+import { createContext } from 'react';
+import type { GetComponent } from '../OcTable.types';
+import type { FixedInfo } from '../Utilities/fixUtil';
+
+export interface TableContextProps {
+ getComponent: GetComponent;
+ scrollbarSize: number;
+ direction: string;
+ fixedInfoList: readonly FixedInfo[];
+ isSticky: boolean;
+}
+
+const TableContext = createContext(null);
+
+export default TableContext;
diff --git a/src/components/Table/Internal/FixedHolder/FixedHolder.types.ts b/src/components/Table/Internal/FixedHolder/FixedHolder.types.ts
new file mode 100644
index 000000000..1fa649620
--- /dev/null
+++ b/src/components/Table/Internal/FixedHolder/FixedHolder.types.ts
@@ -0,0 +1,19 @@
+import type { HeaderProps } from '../Header/Header.types';
+
+export interface FixedHeaderProps extends HeaderProps {
+ classNames: string;
+ noData: boolean;
+ maxContentScroll: boolean;
+ colWidths: readonly number[];
+ columCount: number;
+ direction: string;
+ fixHeader: boolean;
+ stickyTopOffset?: number;
+ stickyBottomOffset?: number;
+ stickyClassName?: string;
+ onScroll: (info: {
+ currentTarget: HTMLDivElement;
+ scrollLeft?: number;
+ }) => void;
+ children: (info: HeaderProps) => React.ReactNode;
+}
diff --git a/src/components/Table/Internal/FixedHolder/index.tsx b/src/components/Table/Internal/FixedHolder/index.tsx
new file mode 100644
index 000000000..cec280046
--- /dev/null
+++ b/src/components/Table/Internal/FixedHolder/index.tsx
@@ -0,0 +1,216 @@
+import React, {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useContext,
+ useMemo,
+ useRef,
+} from 'react';
+import {
+ fillRef,
+ isStyleSupport,
+ mergeClasses,
+} from '../../../../shared/utilities';
+import ColGroup from '../ColGroup';
+import type { ColumnsType, ColumnType } from '../OcTable.types';
+import { FixedHeaderProps } from './FixedHolder.types';
+import TableContext from '../Context/TableContext';
+
+import styles from '../octable.module.scss';
+
+function useColumnWidth(colWidths: readonly number[], columCount: number) {
+ return useMemo(() => {
+ const cloneColumns: number[] = [];
+ for (let i = 0; i < columCount; i += 1) {
+ const val = colWidths[i];
+ if (val !== undefined) {
+ cloneColumns[i] = val;
+ } else {
+ return null;
+ }
+ }
+ return cloneColumns;
+ }, [colWidths.join('_'), columCount]);
+}
+
+const FixedHolder = forwardRef>(
+ (
+ {
+ classNames,
+ noData,
+ columns,
+ flattenColumns,
+ colWidths,
+ columCount,
+ stickyOffsets,
+ direction,
+ fixHeader,
+ stickyTopOffset,
+ stickyBottomOffset,
+ stickyClassName,
+ onScroll,
+ maxContentScroll,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const { scrollbarSize, isSticky } = useContext(TableContext);
+ const supportedScrollbarSize: number = isStyleSupport(
+ 'scrollbar-gutter'
+ )
+ ? 0
+ : scrollbarSize;
+ const combinationScrollBarSize: number =
+ isSticky && !fixHeader ? 0 : supportedScrollbarSize;
+ const scrollRef: React.MutableRefObject =
+ useRef(null);
+
+ const setScrollRef = useCallback((element: HTMLElement) => {
+ fillRef(ref, element);
+ fillRef(scrollRef, element);
+ }, []);
+
+ useEffect(() => {
+ function onWheel(e: WheelEvent) {
+ const { currentTarget, deltaX } =
+ e as unknown as React.WheelEvent;
+ if (deltaX) {
+ onScroll({
+ currentTarget,
+ scrollLeft: currentTarget.scrollLeft + deltaX,
+ });
+ e.preventDefault();
+ }
+ }
+ scrollRef.current?.addEventListener('wheel', onWheel);
+
+ return () => {
+ scrollRef.current?.removeEventListener('wheel', onWheel);
+ };
+ }, []);
+
+ // Check if all flattenColumns has width
+ const allFlattenColumnsWithWidth: boolean = useMemo(
+ () => flattenColumns.every((column) => column.width >= 0),
+ [flattenColumns]
+ );
+
+ // Add scrollbar column
+ const lastColumn: ColumnType =
+ flattenColumns[flattenColumns.length - 1];
+ const ScrollBarColumn: ColumnType & { scrollbar: true } = {
+ fixed: lastColumn ? lastColumn.fixed : null,
+ scrollbar: true,
+ onHeaderCell: () => ({
+ className: styles.tableCellScrollbar,
+ }),
+ };
+
+ const columnsWithScrollbar: ColumnsType = useMemo<
+ ColumnsType
+ >(
+ () =>
+ combinationScrollBarSize
+ ? [...columns, ScrollBarColumn]
+ : columns,
+ [combinationScrollBarSize, columns]
+ );
+
+ const flattenColumnsWithScrollbar: readonly ColumnType[] =
+ useMemo(
+ () =>
+ combinationScrollBarSize
+ ? [...flattenColumns, ScrollBarColumn]
+ : flattenColumns,
+ [combinationScrollBarSize, flattenColumns]
+ );
+
+ // Calculate the sticky offsets
+ const headerStickyOffsets: {
+ left: readonly number[];
+ right: readonly number[];
+ isSticky: boolean;
+ } = useMemo(() => {
+ const { right, left } = stickyOffsets;
+ return {
+ ...stickyOffsets,
+ left:
+ direction === 'rtl'
+ ? [
+ ...left.map(
+ (width) => width + combinationScrollBarSize
+ ),
+ 0,
+ ]
+ : left,
+ right:
+ direction === 'rtl'
+ ? right
+ : [
+ ...right.map(
+ (width) => width + combinationScrollBarSize
+ ),
+ 0,
+ ],
+ isSticky,
+ };
+ }, [combinationScrollBarSize, stickyOffsets, isSticky]);
+
+ const mergedColumnWidth: number[] = useColumnWidth(
+ colWidths,
+ columCount
+ );
+
+ return (
+
+
+ {(!noData ||
+ !maxContentScroll ||
+ allFlattenColumnsWithWidth) && (
+
+ )}
+ {children({
+ ...props,
+ stickyOffsets: headerStickyOffsets,
+ columns: columnsWithScrollbar,
+ flattenColumns: flattenColumnsWithScrollbar,
+ })}
+
+
+ );
+ }
+);
+
+FixedHolder.displayName = 'FixedHolder';
+
+export default FixedHolder;
diff --git a/src/components/Table/Internal/Footer/Cell.tsx b/src/components/Table/Internal/Footer/Cell.tsx
new file mode 100644
index 000000000..c4fd86fe3
--- /dev/null
+++ b/src/components/Table/Internal/Footer/Cell.tsx
@@ -0,0 +1,45 @@
+import React, { useContext } from 'react';
+import SummaryContext from './SummaryContext';
+import Cell from '../Cell';
+import TableContext from '../Context/TableContext';
+import { SummaryCellProps } from './Footer.types';
+import { getCellFixedInfo } from '../Utilities/fixUtil';
+
+export default function SummaryCell({
+ classNames,
+ index,
+ children,
+ colSpan = 1,
+ rowSpan,
+ align,
+}: SummaryCellProps) {
+ const { direction } = useContext(TableContext);
+ const { scrollColumnIndex, stickyOffsets, flattenColumns } =
+ useContext(SummaryContext);
+ const lastIndex = index + colSpan - 1;
+ const mergedColSpan =
+ lastIndex + 1 === scrollColumnIndex ? colSpan + 1 : colSpan;
+
+ const fixedInfo = getCellFixedInfo(
+ index,
+ index + mergedColSpan - 1,
+ flattenColumns,
+ stickyOffsets,
+ direction
+ );
+
+ return (
+ children}
+ {...fixedInfo}
+ />
+ );
+}
diff --git a/src/components/Table/Internal/Footer/Footer.types.ts b/src/components/Table/Internal/Footer/Footer.types.ts
new file mode 100644
index 000000000..cefd993d6
--- /dev/null
+++ b/src/components/Table/Internal/Footer/Footer.types.ts
@@ -0,0 +1,31 @@
+import type { AlignType, ColumnType, StickyOffsets } from '../OcTable.types';
+
+export type FlattenColumns = readonly (ColumnType & {
+ scrollbar?: boolean;
+})[];
+
+export interface SummaryCellProps {
+ classNames?: string;
+ children?: React.ReactNode;
+ index: number;
+ colSpan?: number;
+ rowSpan?: number;
+ align?: AlignType;
+}
+
+export interface SummaryProps {
+ fixed?: boolean | 'top' | 'bottom';
+ children?: React.ReactNode;
+}
+
+export interface FooterRowProps {
+ children?: React.ReactNode;
+ classNames?: string;
+ style?: React.CSSProperties;
+}
+
+export interface FooterProps |