diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index dc90e5a1c3..0000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,17 +0,0 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 60 -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 -# Issues with these labels will never be considered stale -exemptLabels: - - pinned - - security -# Label to use when marking an issue as stale -staleLabel: wontfix -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false diff --git a/.github/workflows/nodejs-ci.yml b/.github/workflows/nodejs-ci.yml index 92724ad5c7..3ff34d31be 100644 --- a/.github/workflows/nodejs-ci.yml +++ b/.github/workflows/nodejs-ci.yml @@ -9,11 +9,13 @@ on: - main - next - v4 + - '*.x' pull_request: branches: - main - next - v4 + - '*.x' jobs: lint: @@ -39,7 +41,7 @@ jobs: react: ['React17', 'React18'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup kernel, increase watchers run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p @@ -49,14 +51,14 @@ jobs: with: node-version: 'lts/*' - name: Cache Node.js modules - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.OS }}-node- ${{ runner.OS }}- - + - name: Install dependencies run: npm install @@ -73,8 +75,8 @@ jobs: env: CI: true BROWSER: ${{ matrix.browser }} - + - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 with: flags: ${{ matrix.browser }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000000..4a06dcbe99 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,19 @@ +# https://github.com/actions/stale#usage +name: 'Close stale issues and PRs' +on: + schedule: + - cron: '30 1 * * *' + +# https://github.com/actions/stale#recommended-permissions +permissions: + contents: write # only for delete-branch option + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v8 + with: + any-of-labels: 'status: Needs More Info' diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a70b4d4e2..80435db93a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# [5.40.0](https://github.com/rsuite/rsuite/compare/v5.39.0...v5.40.0) (2023-09-10) + +### Bug Fixes + +- **CheckTree:** searchKeyword is not updated as expected ([#3354](https://github.com/rsuite/rsuite/issues/3354)) ([b642da3](https://github.com/rsuite/rsuite/commit/b642da31c158481eeea8d46e9e3088edfb83acd1)), closes [#3196](https://github.com/rsuite/rsuite/issues/3196) + +# [5.39.0](https://github.com/rsuite/rsuite/compare/v5.38.0...v5.39.0) (2023-09-04) + +### Bug Fixes + +- **Nav.Item:** fix vertical mis-alignment of icon ([#3344](https://github.com/rsuite/rsuite/issues/3344)) ([283c013](https://github.com/rsuite/rsuite/commit/283c013b656e19f12ec9b628af83fb1798bc5086)) + +### Features + +- **i18n:** Create ne_NP.ts for Nepali Locale Support. ([#3351](https://github.com/rsuite/rsuite/issues/3351)) ([4b16982](https://github.com/rsuite/rsuite/commit/4b16982b5ac5cec06d9eb707b1ba37ae616cf19f)) + +### Reverts + +- Revert "ci: run ci check on \*.x branches" ([0a98b0c](https://github.com/rsuite/rsuite/commit/0a98b0c765270923f321f1d859ccebbf7adeed37)) + # [5.38.0](https://github.com/rsuite/rsuite/compare/v5.37.4...v5.38.0) (2023-08-18) ### Bug Fixes diff --git a/docs/package-lock.json b/docs/package-lock.json index 265ea8c3c9..5ae51f6682 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -1,12 +1,12 @@ { "name": "docs", - "version": "5.38.0", + "version": "5.40.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "docs", - "version": "5.38.0", + "version": "5.40.0", "license": "MIT", "dependencies": { "@docsearch/react": "^3.2.1", @@ -35,7 +35,7 @@ "react-icons": "^4.2.0", "react-json-tree": "^0.17.0", "react-select": "^5.5.9", - "rsuite": "^5.38.0", + "rsuite": "^5.40.0", "svg-sprite-loader": "^6.0.11", "svgo": "^2.3.1", "svgo-loader": "^3.0.3", @@ -10335,9 +10335,9 @@ } }, "node_modules/rsuite": { - "version": "5.38.0", - "resolved": "https://registry.npmjs.org/rsuite/-/rsuite-5.38.0.tgz", - "integrity": "sha512-M3mwI3Jt1kL5OQZFzw57mDn8X+4cDAMG35Oeb7jE0ZSSndKJ8CS4OTifh187ZgDTwvWFyBDrKF3ggCA+1mrG0A==", + "version": "5.40.0", + "resolved": "https://registry.npmjs.org/rsuite/-/rsuite-5.40.0.tgz", + "integrity": "sha512-zhfbbhuWKXjTrvWnFw6Cu+FOHGZlPRwUkP8lnhTmfCI5r4Mp10J4ShgfdeGeqWEMwq9Hz58I540WoDV20PZseQ==", "dependencies": { "@babel/runtime": "^7.20.1", "@juggle/resize-observer": "^3.4.0", @@ -10353,7 +10353,7 @@ "prop-types": "^15.8.1", "react-use-set": "^1.0.0", "react-window": "^1.8.8", - "rsuite-table": "^5.11.0", + "rsuite-table": "^5.12.0", "schema-typed": "^2.1.3" }, "peerDependencies": { @@ -10362,9 +10362,9 @@ } }, "node_modules/rsuite-table": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/rsuite-table/-/rsuite-table-5.11.0.tgz", - "integrity": "sha512-KDNcVdz7ROrAkDob/q5aUXcinhNIRCso+QeYFwzP8vvFFo8LebQ4S5JCPfCjI+Aw45ztUfWsd0tOEdusiIuxUg==", + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/rsuite-table/-/rsuite-table-5.12.0.tgz", + "integrity": "sha512-Di7PPjVgoAYs+FANJ37xKfcInw/MK7+wvxDEVDvfsM0zCiXGTnfQMv+ahXEcBAEU776fMlLAQWbCpXIUzXCHFg==", "dependencies": { "@babel/runtime": "^7.12.5", "@juggle/resize-observer": "^3.3.1", @@ -20902,9 +20902,9 @@ } }, "rsuite": { - "version": "5.38.0", - "resolved": "https://registry.npmjs.org/rsuite/-/rsuite-5.38.0.tgz", - "integrity": "sha512-M3mwI3Jt1kL5OQZFzw57mDn8X+4cDAMG35Oeb7jE0ZSSndKJ8CS4OTifh187ZgDTwvWFyBDrKF3ggCA+1mrG0A==", + "version": "5.40.0", + "resolved": "https://registry.npmjs.org/rsuite/-/rsuite-5.40.0.tgz", + "integrity": "sha512-zhfbbhuWKXjTrvWnFw6Cu+FOHGZlPRwUkP8lnhTmfCI5r4Mp10J4ShgfdeGeqWEMwq9Hz58I540WoDV20PZseQ==", "requires": { "@babel/runtime": "^7.20.1", "@juggle/resize-observer": "^3.4.0", @@ -20920,14 +20920,14 @@ "prop-types": "^15.8.1", "react-use-set": "^1.0.0", "react-window": "^1.8.8", - "rsuite-table": "^5.11.0", + "rsuite-table": "^5.12.0", "schema-typed": "^2.1.3" } }, "rsuite-table": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/rsuite-table/-/rsuite-table-5.11.0.tgz", - "integrity": "sha512-KDNcVdz7ROrAkDob/q5aUXcinhNIRCso+QeYFwzP8vvFFo8LebQ4S5JCPfCjI+Aw45ztUfWsd0tOEdusiIuxUg==", + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/rsuite-table/-/rsuite-table-5.12.0.tgz", + "integrity": "sha512-Di7PPjVgoAYs+FANJ37xKfcInw/MK7+wvxDEVDvfsM0zCiXGTnfQMv+ahXEcBAEU776fMlLAQWbCpXIUzXCHFg==", "requires": { "@babel/runtime": "^7.12.5", "@juggle/resize-observer": "^3.3.1", diff --git a/docs/package.json b/docs/package.json index d2280beeb7..9d4624b587 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "docs", - "version": "5.38.0", + "version": "5.40.0", "private": true, "scripts": { "check:type": "tsc", @@ -43,7 +43,7 @@ "react-icons": "^4.2.0", "react-json-tree": "^0.17.0", "react-select": "^5.5.9", - "rsuite": "^5.38.0", + "rsuite": "^5.40.0", "svg-sprite-loader": "^6.0.11", "svgo": "^2.3.1", "svgo-loader": "^3.0.3", diff --git a/docs/pages/components/calendar/en-US/index.md b/docs/pages/components/calendar/en-US/index.md index dd863f014f..a90725b322 100644 --- a/docs/pages/components/calendar/en-US/index.md +++ b/docs/pages/components/calendar/en-US/index.md @@ -12,6 +12,12 @@ A component that displays data by calendar +### Custom cell styles + +Use `cellClassName` function to specify the custom class name added to each cell. For example, in the following code, we specify that the `.bg-gray` class name is added on Monday, Wednesday, and Friday, so that the background color of the cells in these three columns is gray. + + + ### Compact diff --git a/docs/pages/components/calendar/fragments/custom-cell.md b/docs/pages/components/calendar/fragments/custom-cell.md new file mode 100644 index 0000000000..71baaefc04 --- /dev/null +++ b/docs/pages/components/calendar/fragments/custom-cell.md @@ -0,0 +1,24 @@ + + +```js +import { Calendar } from 'rsuite'; + +const App = () => { + return ( + <> + + (date.getDay() % 2 ? 'bg-gray' : undefined)} /> + + ); +}; + +ReactDOM.render(, document.getElementById('root')); +``` + + diff --git a/docs/pages/components/calendar/zh-CN/index.md b/docs/pages/components/calendar/zh-CN/index.md index 43a962a776..3f0ba63dcd 100644 --- a/docs/pages/components/calendar/zh-CN/index.md +++ b/docs/pages/components/calendar/zh-CN/index.md @@ -12,6 +12,12 @@ +### 自定义单元格样式 + +使用 `cellClassName` 函数指定各单元格添加的自定义类名。例如,下面的代码中,我们指定周一、周三、周五添加 `.bg-gray` 类名,从而实现这三列的单元格背景色为灰色。 + + + ### 紧凑型 diff --git a/docs/pages/guide/v5-features/en-US/index.md b/docs/pages/guide/v5-features/en-US/index.md index 55110b3f03..55dcf40cbd 100644 --- a/docs/pages/guide/v5-features/en-US/index.md +++ b/docs/pages/guide/v5-features/en-US/index.md @@ -365,7 +365,7 @@ All pop-up notification messages are managed using the new API toaster. The Aler // for rsuite v4 Alert.info('description'); -// for rsutie v5 +// for rsuite v5 toaster.push( description diff --git a/docs/pages/guide/v5-features/zh-CN/index.md b/docs/pages/guide/v5-features/zh-CN/index.md index 77300f3fa2..bdbcdf0966 100644 --- a/docs/pages/guide/v5-features/zh-CN/index.md +++ b/docs/pages/guide/v5-features/zh-CN/index.md @@ -367,7 +367,7 @@ return ( // for rsuite v4 Alert.info('description'); -// for rsutie v5 +// for rsuite v5 toaster.push( description diff --git a/package-lock.json b/package-lock.json index ceecf92d83..8dd2056a3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rsuite", - "version": "5.38.0", + "version": "5.40.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "rsuite", - "version": "5.38.0", + "version": "5.40.0", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.1", @@ -23,7 +23,7 @@ "prop-types": "^15.8.1", "react-use-set": "^1.0.0", "react-window": "^1.8.8", - "rsuite-table": "^5.11.0", + "rsuite-table": "^5.12.0", "schema-typed": "^2.1.3" }, "devDependencies": { @@ -112,6 +112,7 @@ "make-dir": "^1.3.0", "minimist": "^1.2.8", "mocha": "^9.0.2", + "picocolors": "^1.0.0", "postcss-custom-properties": "^10.0.0", "postcss-less": "^6.0.0", "prettier": "^2.8.8", @@ -17283,9 +17284,10 @@ }, "node_modules/picocolors": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "resolved": "https://npm.hypers.cc/picocolors/-/picocolors-1.0.0.tgz", "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -18763,9 +18765,9 @@ } }, "node_modules/rsuite-table": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/rsuite-table/-/rsuite-table-5.11.0.tgz", - "integrity": "sha512-KDNcVdz7ROrAkDob/q5aUXcinhNIRCso+QeYFwzP8vvFFo8LebQ4S5JCPfCjI+Aw45ztUfWsd0tOEdusiIuxUg==", + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/rsuite-table/-/rsuite-table-5.12.0.tgz", + "integrity": "sha512-Di7PPjVgoAYs+FANJ37xKfcInw/MK7+wvxDEVDvfsM0zCiXGTnfQMv+ahXEcBAEU776fMlLAQWbCpXIUzXCHFg==", "dependencies": { "@babel/runtime": "^7.12.5", "@juggle/resize-observer": "^3.3.1", @@ -36197,7 +36199,7 @@ }, "picocolors": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "resolved": "https://npm.hypers.cc/picocolors/-/picocolors-1.0.0.tgz", "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", "dev": true }, @@ -37312,9 +37314,9 @@ } }, "rsuite-table": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/rsuite-table/-/rsuite-table-5.11.0.tgz", - "integrity": "sha512-KDNcVdz7ROrAkDob/q5aUXcinhNIRCso+QeYFwzP8vvFFo8LebQ4S5JCPfCjI+Aw45ztUfWsd0tOEdusiIuxUg==", + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/rsuite-table/-/rsuite-table-5.12.0.tgz", + "integrity": "sha512-Di7PPjVgoAYs+FANJ37xKfcInw/MK7+wvxDEVDvfsM0zCiXGTnfQMv+ahXEcBAEU776fMlLAQWbCpXIUzXCHFg==", "requires": { "@babel/runtime": "^7.12.5", "@juggle/resize-observer": "^3.3.1", diff --git a/package.json b/package.json index f8d5fe5054..67ac803e54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rsuite", - "version": "5.38.0", + "version": "5.40.0", "description": "A suite of react components", "main": "lib/cjs/index.js", "module": "lib/esm/index.js", @@ -66,7 +66,7 @@ "prop-types": "^15.8.1", "react-use-set": "^1.0.0", "react-window": "^1.8.8", - "rsuite-table": "^5.11.0", + "rsuite-table": "^5.12.0", "schema-typed": "^2.1.3" }, "peerDependencies": { @@ -164,6 +164,7 @@ "make-dir": "^1.3.0", "minimist": "^1.2.8", "mocha": "^9.0.2", + "picocolors": "^1.0.0", "postcss-custom-properties": "^10.0.0", "postcss-less": "^6.0.0", "prettier": "^2.8.8", diff --git a/scripts/release.js b/scripts/release.js index b1d04a45c1..82a00f724e 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -28,10 +28,16 @@ async function run() { const { stdout: currentBranch } = await $`git branch --show-current`; - if (currentBranch !== 'main' && !isDryRun) { - console.info('You could only run this script on `main` branch or with `--dry-run` flag'); + if (!isReleaseBranch(currentBranch) && !isDryRun) { + console.info('You could only run this script on `main`/`*.x` branch or with `--dry-run` flag'); process.exit(1); } + + const pc = (await import('picocolors')).default; + + console.info(`NOTICE: You're doing release on ${pc.bold(currentBranch)} branch`); + console.log(); + const inquirer = (await import('inquirer')).default; const semver = require('semver'); const currentVersion = require('../package.json').version; @@ -126,3 +132,7 @@ async function run() { } run(); + +function isReleaseBranch(branch) { + return branch === 'main' || /\d\.x/.test(branch); +} diff --git a/src/CheckTreePicker/CheckTreePicker.tsx b/src/CheckTreePicker/CheckTreePicker.tsx index a03a91b224..e312da9425 100644 --- a/src/CheckTreePicker/CheckTreePicker.tsx +++ b/src/CheckTreePicker/CheckTreePicker.tsx @@ -243,7 +243,7 @@ const CheckTreePicker: PickerComponent = React.forwardRef( }).filter(item => item.visible); } - return getFormattedTree(filteredData, flattenNodes, { + return getFormattedTree(flattenNodes, filteredData, { childrenKey, cascade }).map(node => render?.(node, 1)); diff --git a/src/CheckTreePicker/test/CheckTreePickerSpec.tsx b/src/CheckTreePicker/test/CheckTreePickerSpec.tsx index 9e2e81a37e..d943655b62 100644 --- a/src/CheckTreePicker/test/CheckTreePickerSpec.tsx +++ b/src/CheckTreePicker/test/CheckTreePickerSpec.tsx @@ -730,4 +730,16 @@ describe('CheckTreePicker', () => { fireEvent.click(screen.getByLabelText('Clear')); expect(screen.getByRole('combobox')).to.text('Master (All)1'); }); + + it('Should render correctly when searchKeyword changed', () => { + const { rerender } = render(); + + expect(screen.getAllByRole('treeitem')).to.have.lengthOf(2); + rerender(); + expect(screen.getAllByRole('treeitem')).to.have.lengthOf(1); + rerender(); + expect(screen.getAllByRole('treeitem')).to.have.lengthOf(1); + rerender(); + expect(screen.queryAllByRole('treeitem')).to.have.lengthOf(0); + }); }); diff --git a/src/CheckTreePicker/utils.ts b/src/CheckTreePicker/utils.ts index a3d08108c8..afba738804 100644 --- a/src/CheckTreePicker/utils.ts +++ b/src/CheckTreePicker/utils.ts @@ -114,8 +114,8 @@ export function isNodeUncheckable( } export function getFormattedTree( - data: any[], nodes: TreeNodesType, + data: any[], props: Required> ) { const { childrenKey, cascade } = props; @@ -132,7 +132,7 @@ export function getFormattedTree( attachParent(formatted, curNode.parent); formatted.checkState = checkState; if (node[childrenKey]?.length > 0) { - formatted[childrenKey] = getFormattedTree(formatted[childrenKey], nodes, props); + formatted[childrenKey] = getFormattedTree(nodes, formatted[childrenKey], props); } } diff --git a/src/Form/Form.tsx b/src/Form/Form.tsx index 7efb88274d..43f596f546 100644 --- a/src/Form/Form.tsx +++ b/src/Form/Form.tsx @@ -385,7 +385,6 @@ const Form: FormComponent = React.forwardRef((props: FormProps, ref: React.Ref ({ getCombinedModel, checkTrigger, - formDefaultValue, errorFromContext, readOnly, plaintext, @@ -402,7 +401,6 @@ const Form: FormComponent = React.forwardRef((props: FormProps, ref: React.Ref - {children} + {children} ); diff --git a/src/Form/FormContext.tsx b/src/Form/FormContext.tsx index 20e34be1ef..6777769773 100644 --- a/src/Form/FormContext.tsx +++ b/src/Form/FormContext.tsx @@ -19,9 +19,8 @@ interface TrulyFormContextValue< onFieldSuccess: (name: string) => void; } -type ExternalPropsContextValue = { +type ExternalPropsContextValue = { checkTrigger?: TypeAttributes.CheckTrigger; - formDefaultValue?: T; errorFromContext?: boolean; readOnly?: boolean; plaintext?: boolean; @@ -34,7 +33,7 @@ export type FormContextValue, errorMsgType = any> = ( | TrulyFormContextValue | InitialContextType ) & - ExternalPropsContextValue; + ExternalPropsContextValue; export const FormContext = React.createContext({}); export const FormValueContext = React.createContext | undefined>({}); diff --git a/src/FormControl/FormControl.tsx b/src/FormControl/FormControl.tsx index 030899c9a4..d6994072e4 100644 --- a/src/FormControl/FormControl.tsx +++ b/src/FormControl/FormControl.tsx @@ -72,7 +72,6 @@ const FormControl: FormControlComponent = React.forwardRef((props: FormControlPr plaintext: plaintextContext, disabled: disabledContext, errorFromContext, - formDefaultValue = {}, formError, removeFieldValue, removeFieldError, @@ -187,17 +186,14 @@ const FormControl: FormControlComponent = React.forwardRef((props: FormControlPr const ariaErrormessage = fieldHasError && controlId ? `${controlId}-error-message` : undefined; let valueKey = 'value'; - let defaultValueKey = 'defaultValue'; // Toggle component is a special case that uses `checked` and `defaultChecked` instead of `value` and `defaultValue` props. if (AccepterComponent === Toggle) { valueKey = 'checked'; - defaultValueKey = 'defaultChecked'; } const accepterProps = { - [valueKey]: val, - [defaultValueKey]: defaultValue ?? formDefaultValue[name] + [valueKey]: val ?? defaultValue }; return ( diff --git a/src/FormControl/test/FormControlSpec.tsx b/src/FormControl/test/FormControlSpec.tsx index fec6060fd7..0fd4d652ed 100644 --- a/src/FormControl/test/FormControlSpec.tsx +++ b/src/FormControl/test/FormControlSpec.tsx @@ -32,7 +32,7 @@ describe('FormControl', () => { it('Should call onChange callback', () => { const onChange = sinon.spy(); render( -
+ ); @@ -149,15 +149,28 @@ describe('FormControl', () => { expect(screen.getByRole('textbox')).to.have.value(mockValue); }); - it('Should render correctly default value when explicitly set over form default', () => { - const mockValue = 'value'; - render( -
- - - ); + describe('defaultValue', () => { + it("Should render current value when field's defaultValue was set", () => { + const correctValue = 'value'; + render( +
+ + + ); - expect(screen.getByRole('textbox')).to.have.value(mockValue); + expect(screen.getByRole('textbox')).to.have.value(correctValue); + }); + + it("Should render Form's when both the defaultValue of the form and the defaultValue of the field were set", () => { + const correctValue = 'value'; + render( +
+ + + ); + + expect(screen.getByRole('textbox')).to.have.value(correctValue); + }); }); it('Should render correctly when form error was null', () => { diff --git a/src/Nav/styles/index.less b/src/Nav/styles/index.less index dd39799a38..ee8d5b4ff4 100644 --- a/src/Nav/styles/index.less +++ b/src/Nav/styles/index.less @@ -98,6 +98,12 @@ .rs-nav-horizontal { white-space: nowrap; + > .rs-nav-item { + display: inline-flex; + align-items: center; + vertical-align: top; + } + // Waterline .rs-nav-bar { position: absolute; @@ -112,19 +118,12 @@ } } -.rs-nav-item, -.rs-dropdown { - .rs-nav-horizontal > & { - display: inline-block; - vertical-align: top; - } - - .rs-nav-vertical > & { - display: block; +.rs-nav-vertical { + > .rs-nav-item { + display: flex; + align-items: center; } -} -.rs-nav-vertical { > .rs-dropdown { width: 100%; diff --git a/src/SelectPicker/Listbox.tsx b/src/SelectPicker/Listbox.tsx new file mode 100644 index 0000000000..7c54c3c00d --- /dev/null +++ b/src/SelectPicker/Listbox.tsx @@ -0,0 +1,304 @@ +import React, { useRef, useState, useEffect, useCallback } from 'react'; +import isUndefined from 'lodash/isUndefined'; +import getPosition from 'dom-lib/getPosition'; +import scrollTop from 'dom-lib/scrollTop'; +import getHeight from 'dom-lib/getHeight'; +import { List, AutoSizer, ListProps, ListHandle } from '../Windowing'; +import shallowEqual from '../utils/shallowEqual'; +import { mergeRefs, useClassNames, useMount } from '../utils'; +import ListboxOptionGroup from './ListboxOptionGroup'; +import { CompareFn, Group, groupOptions } from '../utils/getDataGroupBy'; +import { StandardProps, Offset } from '../@types/common'; +import ListboxOption from './ListboxOption'; + +interface ListboxProps + extends StandardProps, + Omit, 'onSelect'> { + classPrefix: string; + options: readonly T[]; + getOptionKey?: (option: T) => K; + sort?: (isGroup: B) => B extends true ? CompareFn> : CompareFn; + groupBy?: string; + disabledOptionKeys?: readonly K[]; + selectedOptionKey?: K; + activeOptionKey?: K; + maxHeight?: number; + labelKey?: string; + className?: string; + style?: React.CSSProperties; + optionClassPrefix?: string; + rowHeight?: number; + rowGroupHeight?: number; + virtualized?: boolean; + listProps?: Partial; + listRef?: React.Ref; + + /** Custom selected option */ + renderMenuItem?: (itemLabel: React.ReactNode, item: any) => React.ReactNode; + renderMenuGroup?: (title: React.ReactNode, item: any) => React.ReactNode; + onSelect?: (value: K, item: T, event: React.MouseEvent) => void; + onGroupTitleClick?: (event: React.MouseEvent) => void; +} + +type ListboxComponent = ( + p: ListboxProps & { ref?: React.ForwardedRef } +) => JSX.Element; + +const Listbox = React.forwardRef(function Listbox( + props: ListboxProps, + ref: React.ForwardedRef +) { + const { + options = [], + getOptionKey, + groupBy, + sort, + maxHeight = 320, + selectedOptionKey, + disabledOptionKeys = [], + classPrefix = 'dropdown-menu', + labelKey = 'label', + virtualized, + listProps, + listRef: virtualizedListRef, + className, + style, + activeOptionKey, + optionClassPrefix, + rowHeight = 36, + rowGroupHeight = 48, + renderMenuGroup, + renderMenuItem, + onGroupTitleClick, + onSelect, + ...rest + } = props; + + const group = typeof groupBy !== 'undefined'; + + const { withClassPrefix, prefix, merge } = useClassNames(classPrefix); + const classes = merge(className, withClassPrefix('items', { grouped: group })); + + const menuBodyContainerRef = useRef(null); + const listRef = useRef(null); + + const [foldedGroupKeys, setFoldedGroupKeys] = useState([]); + + const handleGroupTitleClick = useCallback( + (key: string, event: React.MouseEvent) => { + const nextGroupKeys = foldedGroupKeys.filter(item => item !== key); + if (nextGroupKeys.length === foldedGroupKeys.length) { + nextGroupKeys.push(key); + } + setFoldedGroupKeys(nextGroupKeys); + onGroupTitleClick?.(event); + + // See example https://codesandbox.io/s/grouped-list-with-sticky-headers-shgok?fontsize=14&file=/index.js:1314-1381 + listRef.current?.resetAfterIndex(0); // use group index to reduce calculation + }, + [onGroupTitleClick, foldedGroupKeys] + ); + + useEffect(() => { + const container = menuBodyContainerRef.current; + + if (!container) { + return; + } + + let activeItem = container.querySelector(`.${prefix('item-focus')}`); + + if (!activeItem) { + activeItem = container.querySelector(`.${prefix('item-active')}`); + } + + if (!activeItem) { + return; + } + + const position = getPosition(activeItem, container) as Offset; + const sTop = scrollTop(container); + const sHeight = getHeight(container); + if (sTop > position.top) { + scrollTop(container, Math.max(0, position.top - 20)); + } else if (position.top > sTop + sHeight) { + scrollTop(container, Math.max(0, position.top - sHeight + 32)); + } + }, [activeOptionKey, menuBodyContainerRef, prefix]); + + useMount(function scrollToSelectedOption() { + if (virtualized && selectedOptionKey) { + if (typeof groupBy === 'undefined') { + const selectedOptionIndex = options.findIndex( + option => getOptionKey?.(option) === selectedOptionKey + ); + listRef.current?.scrollToItem(selectedOptionIndex); + } else { + const groups = groupOptions(options, groupBy, sort?.(false), sort?.(true)); + const selectedGroupIndex = groups.findIndex(group => group.key === selectedOptionKey); + // TODO-Doma + // This only scrolls the list to the group, not to the selected item within the group + // .scrollToItem does not support specifying an px offset + // Find a way to scroll to the selected item within the group + listRef.current?.scrollToItem(selectedGroupIndex); + } + } + }); + + const renderOption = useCallback( + (option: T) => { + const optionKey = getOptionKey?.(option) ?? JSON.stringify(option); + const label = option[labelKey]; + + const disabled = disabledOptionKeys?.some(disabledValue => + shallowEqual(disabledValue, optionKey) + ); + const selected = shallowEqual(selectedOptionKey, optionKey); + const focus = !isUndefined(activeOptionKey) && shallowEqual(activeOptionKey, optionKey); + + return ( + { + if (!disabled) { + onSelect?.(optionKey as K, option, event); + } + }} + > + {renderMenuItem ? renderMenuItem(label, option) : label} + + ); + }, + [ + getOptionKey, + labelKey, + disabledOptionKeys, + selectedOptionKey, + activeOptionKey, + optionClassPrefix, + renderMenuItem, + onSelect + ] + ); + + const renderOptions = useCallback( + (options: readonly T[]) => { + return options.map(option => renderOption(option)); + }, + [renderOption] + ); + + const renderOptionGroup = useCallback( + (group: Group) => { + const groupKey = group.key; + const expanded = !foldedGroupKeys.includes(groupKey); + + return ( + handleGroupTitleClick(group.key, e)} + > + {renderOptions(group.options)} + + ); + }, + [foldedGroupKeys, handleGroupTitleClick, renderMenuGroup, renderOptions] + ); + + const renderOptionGroups = useCallback( + (groupKey: string) => { + const groups = groupOptions(options, groupKey, sort?.(false), sort?.(true)); + return groups.map(group => renderOptionGroup(group)); + }, + [options, renderOptionGroup, sort] + ); + + const renderVirtualizedOptions = useCallback(() => { + return ( + + {({ height }) => ( + + {({ index }) => renderOption(options[index])} + + )} + + ); + }, [listProps, maxHeight, options, renderOption, rowHeight, virtualizedListRef]); + + // Example of rendering option groups in VariableSizeList + // https://github.com/bvaughn/react-window/issues/358 + const renderVirtualizedOptionGroups = useCallback( + (groupKey: string) => { + const groups = groupOptions(options, groupKey, sort?.(false), sort?.(true)); + return ( + + {({ height }) => ( + { + const item = groups[index]; + + const expanded = !foldedGroupKeys.includes(item.key); + if (expanded) { + return item.options.length * rowHeight + rowGroupHeight; + } + + return rowGroupHeight; + }} + {...listProps} + > + {({ index }) => renderOptionGroup(groups[index])} + + )} + + ); + }, + [ + foldedGroupKeys, + listProps, + maxHeight, + options, + renderOptionGroup, + rowGroupHeight, + rowHeight, + sort, + virtualizedListRef + ] + ); + + return ( +
+ {typeof groupBy === 'undefined' + ? virtualized + ? renderVirtualizedOptions() + : renderOptions(options) + : virtualized + ? renderVirtualizedOptionGroups(groupBy) + : renderOptionGroups(groupBy)} +
+ ); +}) as ListboxComponent; + +export default Listbox; diff --git a/src/SelectPicker/ListboxOption.tsx b/src/SelectPicker/ListboxOption.tsx new file mode 100644 index 0000000000..e1a59f9ca1 --- /dev/null +++ b/src/SelectPicker/ListboxOption.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { useClassNames } from '../utils'; +import { StandardProps } from '../@types/common'; + +interface ListboxOptionProps extends StandardProps, React.HTMLAttributes { + selected?: boolean; + disabled?: boolean; + active?: boolean; + title?: string; + onKeyDown?: (event: React.KeyboardEvent) => void; +} + +const ListboxOption = React.forwardRef(function ListboxOption( + props, + ref +) { + const { + selected, + classPrefix = 'dropdown-menu-item', + children, + className, + disabled, + active, + onKeyDown, + ...rest + } = props; + + const { withClassPrefix } = useClassNames(classPrefix); + const classes = withClassPrefix({ active: selected, focus: active, disabled }); + + return ( +
+ {children} +
+ ); +}); +ListboxOption.displayName = 'Listbox.Option'; + +export default ListboxOption; diff --git a/src/SelectPicker/ListboxOptionGroup.tsx b/src/SelectPicker/ListboxOptionGroup.tsx new file mode 100644 index 0000000000..fdf37ad547 --- /dev/null +++ b/src/SelectPicker/ListboxOptionGroup.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import classNames from 'classnames'; +import { useClassNames } from '../utils'; +import { StandardProps } from '../@types/common'; +import ArrowDown from '@rsuite/icons/legacy/ArrowDown'; +import useUniqueId from '../utils/useUniqueId'; + +interface ListboxOptionGroupProps + extends StandardProps, + Omit, 'title'> { + title?: React.ReactNode; + expanded?: boolean; + onClickTitle?: React.MouseEventHandler; +} + +const ListboxOptionGroup = React.forwardRef( + (props, ref) => { + const { + classPrefix = 'dropdown-menu-group', + title, + children, + className, + expanded = true, + onClickTitle, + ...rest + } = props; + const { withClassPrefix, prefix, merge } = useClassNames(classPrefix); + const classes = merge(className, withClassPrefix()); + + const groupId = useUniqueId('listbox-group-'); + const labelId = groupId + '-label'; + + return ( +
+
+ {title} + +
+ {children} +
+ ); + } +); +ListboxOptionGroup.displayName = 'Listbox.OptionGroup'; + +export default ListboxOptionGroup; diff --git a/src/SelectPicker/SelectPicker.tsx b/src/SelectPicker/SelectPicker.tsx index dd01d4e784..8ec8afc203 100644 --- a/src/SelectPicker/SelectPicker.tsx +++ b/src/SelectPicker/SelectPicker.tsx @@ -1,7 +1,6 @@ import React, { useRef, useState, useCallback, Ref } from 'react'; import PropTypes from 'prop-types'; import pick from 'lodash/pick'; -import isUndefined from 'lodash/isUndefined'; import isNil from 'lodash/isNil'; import isFunction from 'lodash/isFunction'; import omit from 'lodash/omit'; @@ -14,10 +13,7 @@ import { mergeRefs, shallowEqual } from '../utils'; -import { getDataGroupBy } from '../utils/getDataGroupBy'; import { - DropdownMenu, - DropdownMenuItem, PickerToggle, PickerToggleTrigger, PickerOverlay, @@ -39,6 +35,7 @@ import { import { ListProps } from '../Windowing'; import { FormControlPickerProps, ItemDataType } from '../@types/common'; import { ListHandle } from '../Windowing'; +import Listbox from './Listbox'; export interface SelectProps { /** Set group condition key in data */ @@ -62,6 +59,8 @@ export interface SelectProps { searchBy?: (keyword: string, label: React.ReactNode, item: ItemDataType) => boolean; /** Sort options */ + // TODO-Doma + // Deprecate sort(false). Data should be sorted before passed to the component. sort?: (isGroup: boolean) => (a: any, b: any) => number; /** Customizing the Rendering Menu list */ @@ -341,43 +340,36 @@ const SelectPicker = React.forwardRef( const { left, top, className } = positionProps; const classes = merge(className, menuClassName, prefix('select-menu')); const styles = { ...menuStyle, left, top }; - let items = filteredData; - // Create a tree structure data when set `groupBy` - if (groupBy) { - items = getDataGroupBy(items, groupBy, sort); - } else if (typeof sort === 'function') { - items = items.sort(sort(false)); - } + const menu = (() => { + if (!filteredData.length) { + return
{locale?.noResultsText}
; + } - const menu = items.length ? ( - - ) : ( -
{locale?.noResultsText}
- ); + return ( + option[valueKey]} + id={id ? `${id}-listbox` : undefined} + listProps={listProps} + listRef={listRef} + disabledOptionKeys={disabledItemValues as any[]} + labelKey={labelKey} + renderMenuGroup={renderMenuGroup} + renderMenuItem={renderMenuItem} + maxHeight={menuMaxHeight} + classPrefix={'picker-select-menu'} + optionClassPrefix={'picker-select-menu-item'} + selectedOptionKey={value as any} + activeOptionKey={focusItemValue as any} + groupBy={groupBy} + sort={sort} + onSelect={handleItemSelect} + onGroupTitleClick={onGroupTitleClick} + virtualized={virtualized} + /> + ); + })(); return ( .rs-picker-select-menu-item { - padding-left: 26px; - } } // Menu item (the option) @@ -45,3 +41,11 @@ padding-left: @picker-group-children-padding-left; } } + +.rs-picker-menu-group .rs-picker-select-menu-item { + padding-left: @picker-group-children-padding-left; +} + +.rs-picker-menu-group.folded [role='option'] { + display: none; +} diff --git a/src/Windowing/List.tsx b/src/Windowing/List.tsx index c1757c8bd2..74b0eb1371 100644 --- a/src/Windowing/List.tsx +++ b/src/Windowing/List.tsx @@ -11,7 +11,7 @@ import { useCustom } from '../utils'; export interface ListProps extends WithAsProps { /** - * @deprecated use itemSize instead + * @deprecated use {@link itemSize} instead * Either a fixed row height (number) or a function that returns the height of a row given its index: ({ index: number }): number */ rowHeight?: number | (({ index: number }) => number); @@ -47,7 +47,8 @@ export interface ListProps extends WithAsProps { onScroll?: (props: ListOnScrollProps) => void; } -export interface ListHandle extends Partial { +export interface ListHandle + extends Pick { /** * @deprecated use scrollToItem instead * Ensure row is visible. This method can be used to safely scroll back to a cell that a user has scrolled away from even if it was previously scrolled to. diff --git a/src/locales/index.ts b/src/locales/index.ts index a165e61af7..7408adb5b0 100644 --- a/src/locales/index.ts +++ b/src/locales/index.ts @@ -21,6 +21,7 @@ export { default as zhTW } from './zh_TW'; export { default as faIR } from './fa_IR'; export { default as frFR } from './fr_FR'; export { default as jaJP } from './ja_JP'; +export { default as neNP } from './ne_NP'; type PickKeys = { [keys in keyof T]?: T[keys]; diff --git a/src/locales/ne_NP.ts b/src/locales/ne_NP.ts new file mode 100644 index 0000000000..b148b39434 --- /dev/null +++ b/src/locales/ne_NP.ts @@ -0,0 +1,82 @@ +import enGB from 'date-fns/locale/en-GB'; + +const Calendar = { + sunday: 'आ', + monday: 'सो', + tuesday: 'म', + wednesday: 'बु', + thursday: 'बि', + friday: 'शु', + saturday: 'श', + ok: 'हुन्छ', + today: 'आज', + yesterday: 'हिजो', + hours: 'घण्टा', + minutes: 'मिनेट', + seconds: 'सेकेन्ड', + /** + * Format of the string is based on Unicode Technical Standard #35: + * https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table + **/ + formattedMonthPattern: 'MMM yyyy', + formattedDayPattern: 'dd MMM yyyy', + dateLocale: enGB as any +}; + +export default { + common: { + loading: 'लोड हुँदैछ...', + emptyMessage: 'कुनै डाटा छैन' + }, + Plaintext: { + unfilled: 'भरिएको छैन', + notSelected: 'चयन गरिएको छैन', + notUploaded: 'अपलोड गरिएको छैन' + }, + Pagination: { + more: 'थप', + prev: 'अघिल्लो', + next: 'अर्को', + first: 'पहिलो', + last: 'अन्तिम', + limit: '{0} / पृष्ठ', + total: 'कुल पङ्क्तिहरू: {0}', + skip: '{0} पृष्ठमा जानुहोस्' + }, + Calendar, + DatePicker: { + ...Calendar + }, + DateRangePicker: { + ...Calendar, + last7Days: 'पछिल्लो ७ दिन' + }, + Picker: { + noResultsText: 'कुनै परिणाम फेला परेन', + placeholder: 'चयन गर्नुहोस्', + searchPlaceholder: 'खोजी गर्नुहोस्', + checkAll: 'सबै' + }, + InputPicker: { + newItem: 'नयाँ थप्नुहोस्', + createOption: 'विकल्प सिर्जना गर्नुहोस् "{0}"' + }, + Uploader: { + inited: 'प्रारम्भिक', + progress: 'अपलोड गर्दै', + error: 'त्रुटि भयो', + complete: 'समाप्त', + emptyFile: 'खाली', + upload: 'अपलोड गर्नुहोस्' + }, + CloseButton: { + closeLabel: 'बन्द गर्नुहोस्' + }, + Breadcrumb: { + expandText: 'स्थान देखाउनुहोस्' + }, + Toggle: { + on: 'खोल्नुहोस्', + off: 'बन्द गर्नुहोस्' + } +}; diff --git a/src/styles/mixins/listbox.less b/src/styles/mixins/listbox.less index 38d76d8c11..711c7f0591 100644 --- a/src/styles/mixins/listbox.less +++ b/src/styles/mixins/listbox.less @@ -32,6 +32,8 @@ font-weight: normal; line-height: @line-height-base; color: var(--rs-text-primary); + // FIXME-Doma + // No need to use pointer, just use default cursor: pointer; text-decoration: none; width: 100%; diff --git a/src/utils/getDataGroupBy.ts b/src/utils/getDataGroupBy.ts index f94fa02ebf..f08185f61b 100644 --- a/src/utils/getDataGroupBy.ts +++ b/src/utils/getDataGroupBy.ts @@ -5,6 +5,10 @@ const hasSymbol = typeof Symbol === 'function'; export const KEY_GROUP = hasSymbol ? Symbol('_$grouped') : '_$grouped'; export const KEY_GROUP_TITLE = 'groupTitle'; +/** + * Chunk data into groups + * @returns [group, child, child, group, child, child] + */ export function getDataGroupBy( data: readonly T[], key: string, @@ -28,3 +32,33 @@ export function getDataGroupBy( // rather than [group, group, child, child, child, child] return flattenTree(groups, group => group.children, WalkTreeStrategy.DFS); } + +/** + * Chunk options into groups + * @returns [ + * group { + * key + * options + * } + * group { + * key + * options + * } + * ] + */ +export type Group = { key: string; options: T[] }; +export type CompareFn = (a: T, b: T) => number; +export function groupOptions( + options: readonly T[], + groupKey: string, + compareOptions?: CompareFn, + compareGroups?: CompareFn> +): Group[] { + const groupMap = _.groupBy(options, groupKey); + const groups = Object.entries(groupMap).map(([key, options]) => ({ + key, + options: typeof compareOptions === 'function' ? options.sort(compareOptions) : options + })); + + return typeof compareGroups === 'function' ? groups.sort(compareGroups) : groups; +} diff --git a/src/utils/treeUtils.ts b/src/utils/treeUtils.ts index 678579ce3c..f64ea16819 100644 --- a/src/utils/treeUtils.ts +++ b/src/utils/treeUtils.ts @@ -930,11 +930,23 @@ export function useTreeSearch(props: TreeSearchProps) { ); // Use search keywords to filter options. - const [searchKeywordState, setSearchKeyword] = useState(() => searchKeyword ?? ''); + const [searchKeywordState, setSearchKeyword] = useState(searchKeyword ?? ''); const [filteredData, setFilteredData] = useState(() => filterVisibleData(data, searchKeywordState) ); + const handleSearch = (searchKeyword: string, event?: React.ChangeEvent) => { + const filteredData = filterVisibleData(data, searchKeyword); + setFilteredData(filteredData); + setSearchKeyword(searchKeyword); + event && callback?.(searchKeyword, filteredData, event); + }; + + useEffect(() => { + handleSearch(searchKeyword ?? ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchKeyword]); + const handleSetFilteredData = useCallback( (data: T[], searchKeyword: string) => { setFilteredData(filterVisibleData(data, searchKeyword)); @@ -942,13 +954,6 @@ export function useTreeSearch(props: TreeSearchProps) { [filterVisibleData] ); - const handleSearch = (searchKeyword: string, event: React.ChangeEvent) => { - const filteredData = filterVisibleData(data, searchKeyword); - setFilteredData(filteredData); - setSearchKeyword(searchKeyword); - callback?.(searchKeyword, filteredData, event); - }; - return { searchKeywordState, filteredData,