diff --git a/components/__tests__/__snapshots__/index.test.ts.snap b/components/__tests__/__snapshots__/index.test.ts.snap new file mode 100644 index 0000000..7748163 --- /dev/null +++ b/components/__tests__/__snapshots__/index.test.ts.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`antd exports modules correctly 1`] = ` +[ + "Affix", + "Alert", + "Anchor", + "AutoComplete", + "Avatar", + "BackTop", + "Badge", + "Breadcrumb", + "Button", + "Calendar", + "Card", + "Carousel", + "Cascader", + "Checkbox", + "Col", + "Collapse", + "Comment", + "ConfigProvider", + "DatePicker", + "Descriptions", + "Divider", + "Drawer", + "Dropdown", + "Empty", + "Form", + "Grid", + "Image", + "Input", + "InputNumber", + "Layout", + "List", + "Mentions", + "Menu", + "Modal", + "PageHeader", + "Pagination", + "Popconfirm", + "Popover", + "Progress", + "Radio", + "Rate", + "Result", + "Row", + "Segmented", + "Select", + "Skeleton", + "Slider", + "Space", + "Spin", + "Statistic", + "Steps", + "Switch", + "Table", + "Tabs", + "Tag", + "TimePicker", + "Timeline", + "Tooltip", + "Transfer", + "Tree", + "TreeSelect", + "Typography", + "Upload", + "message", + "notification", + "theme", + "version", +] +`; diff --git a/components/__tests__/index.test.ts b/components/__tests__/index.test.ts new file mode 100644 index 0000000..bb11ad6 --- /dev/null +++ b/components/__tests__/index.test.ts @@ -0,0 +1,21 @@ +const OLD_NODE_ENV = process.env.NODE_ENV; +process.env.NODE_ENV = 'development'; +const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); +const antd = require('..'); + +describe('antd', () => { + afterAll(() => { + process.env.NODE_ENV = OLD_NODE_ENV; + }); + + it('exports modules correctly', () => { + expect(Object.keys(antd)).toMatchSnapshot(); + }); + + it('should hint when import all components in dev mode', () => { + expect(warnSpy).toHaveBeenCalledWith( + 'You are using a whole package of antd, please use https://www.npmjs.com/package/babel-plugin-import to reduce app bundle size.', + ); + warnSpy.mockRestore(); + }); +}); diff --git a/components/__tests__/node.test.tsx b/components/__tests__/node.test.tsx new file mode 100644 index 0000000..ff197de --- /dev/null +++ b/components/__tests__/node.test.tsx @@ -0,0 +1,48 @@ +import glob from 'glob'; +import * as React from 'react'; +import { renderToString } from 'react-dom/server'; +import type { Options } from '../../tests/shared/demoTest'; + +(global as any).testConfig = {}; + +jest.mock('../../tests/shared/demoTest', () => { + function fakeDemoTest(name: string, option: Options = {}) { + (global as any).testConfig[name] = option; + } + + return fakeDemoTest; +}); + +describe('node', () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date('2016-11-22')); + }); + + // Find the component exist demo test file + const files = glob.sync(`./components/*/__tests__/demo.test.@(j|t)s?(x)`); + + files.forEach(componentTestFile => { + const componentName = componentTestFile.match(/components\/([^/]*)\//)![1]; + + // Test for ssr + describe(componentName, () => { + const demoList = glob.sync(`./components/${componentName}/demo/*.md`); + + // Use mock to get config + require(`../../${componentTestFile}`); // eslint-disable-line global-require, import/no-dynamic-require + const option = (global as any).testConfig?.[componentName]; + + demoList.forEach(demoFile => { + const skip: string[] = option?.skip || []; + const test = skip.some(skipMarkdown => demoFile.includes(skipMarkdown)) ? it.skip : it; + + test(demoFile, () => { + const Demo = require(`../../${demoFile}`).default; // eslint-disable-line global-require, import/no-dynamic-require + expect(() => { + renderToString(); + }).not.toThrow(); + }); + }); + }); + }); +}); diff --git a/components/_util/ActionButton.tsx b/components/_util/ActionButton.tsx new file mode 100644 index 0000000..a3c4105 --- /dev/null +++ b/components/_util/ActionButton.tsx @@ -0,0 +1,113 @@ +import useState from 'rc-util/lib/hooks/useState'; +import * as React from 'react'; +import Button from '../button'; +import type { ButtonProps, LegacyButtonType } from '../button/button'; +import { convertLegacyProps } from '../button/button'; + +export interface ActionButtonProps { + type?: LegacyButtonType; + actionFn?: (...args: any[]) => any | PromiseLike; + close?: Function; + autoFocus?: boolean; + prefixCls: string; + buttonProps?: ButtonProps; + emitEvent?: boolean; + quitOnNullishReturnValue?: boolean; + children?: React.ReactNode; +} + +function isThenable(thing?: PromiseLike): boolean { + return !!(thing && !!thing.then); +} + +const ActionButton: React.FC = (props) => { + const clickedRef = React.useRef(false); + const ref = React.useRef(null); + const [loading, setLoading] = useState(false); + const { close } = props; + const onInternalClose = (...args: any[]) => { + close?.(...args); + }; + + React.useEffect(() => { + let timeoutId: ReturnType | null = null; + if (props.autoFocus) { + timeoutId = setTimeout(() => { + ref.current?.focus(); + }); + } + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, []); + + const handlePromiseOnOk = (returnValueOfOnOk?: PromiseLike) => { + if (!isThenable(returnValueOfOnOk)) { + return; + } + setLoading(true); + returnValueOfOnOk!.then( + (...args: any[]) => { + setLoading(false, true); + onInternalClose(...args); + clickedRef.current = false; + }, + (e: Error) => { + // See: https://github.com/ant-design/ant-design/issues/6183 + setLoading(false, true); + clickedRef.current = false; + return Promise.reject(e); + }, + ); + }; + + const onClick = (e: React.MouseEvent) => { + const { actionFn } = props; + if (clickedRef.current) { + return; + } + clickedRef.current = true; + if (!actionFn) { + onInternalClose(); + return; + } + let returnValueOfOnOk; + if (props.emitEvent) { + returnValueOfOnOk = actionFn(e); + if (props.quitOnNullishReturnValue && !isThenable(returnValueOfOnOk)) { + clickedRef.current = false; + onInternalClose(e); + return; + } + } else if (actionFn.length) { + returnValueOfOnOk = actionFn(close); + // https://github.com/ant-design/ant-design/issues/23358 + clickedRef.current = false; + } else { + returnValueOfOnOk = actionFn(); + if (!returnValueOfOnOk) { + onInternalClose(); + return; + } + } + handlePromiseOnOk(returnValueOfOnOk); + }; + + const { type, children, prefixCls, buttonProps } = props; + return ( + + ); +}; + +export default ActionButton; diff --git a/components/_util/__tests__/easings.test.ts b/components/_util/__tests__/easings.test.ts new file mode 100644 index 0000000..963b863 --- /dev/null +++ b/components/_util/__tests__/easings.test.ts @@ -0,0 +1,12 @@ +import { easeInOutCubic } from '../easings'; + +describe('Test easings', () => { + it('easeInOutCubic return value', () => { + const nums: number[] = []; + 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/components/_util/__tests__/getScroll.test.ts b/components/_util/__tests__/getScroll.test.ts new file mode 100644 index 0000000..8cb737f --- /dev/null +++ b/components/_util/__tests__/getScroll.test.ts @@ -0,0 +1,57 @@ +import getScroll from '../getScroll'; + +describe('getScroll', () => { + it('getScroll target null', async () => { + expect(getScroll(null, true)).toBe(0); + expect(getScroll(null, false)).toBe(0); + }); + + it('getScroll window', async () => { + const scrollToSpy = jest.spyOn(window, 'scrollTo').mockImplementation((x, y) => { + window.pageXOffset = x; + window.pageYOffset = y; + }); + window.scrollTo(200, 400); + expect(getScroll(window, true)).toBe(400); + expect(getScroll(window, false)).toBe(200); + scrollToSpy.mockRestore(); + }); + + it('getScroll document', async () => { + const scrollToSpy = jest.spyOn(window, 'scrollTo').mockImplementation((x, y) => { + document.documentElement.scrollLeft = x; + document.documentElement.scrollTop = y; + }); + window.scrollTo(200, 400); + expect(getScroll(document, true)).toBe(400); + expect(getScroll(document, false)).toBe(200); + scrollToSpy.mockRestore(); + }); + + it('getScroll div', async () => { + const div = document.createElement('div'); + const scrollToSpy = jest.spyOn(window, 'scrollTo').mockImplementation((x, y) => { + div.scrollLeft = x; + div.scrollTop = y; + }); + window.scrollTo(200, 400); + expect(getScroll(div, true)).toBe(400); + expect(getScroll(div, false)).toBe(200); + scrollToSpy.mockRestore(); + }); + + it('getScroll documentElement', async () => { + const div: any = {}; + const scrollToSpy = jest.spyOn(window, 'scrollTo').mockImplementation((x, y) => { + div.scrollLeft = null; + div.scrollTop = null; + div.documentElement = {}; + div.documentElement.scrollLeft = x; + div.documentElement.scrollTop = y; + }); + window.scrollTo(200, 400); + expect(getScroll(div, true)).toBe(400); + expect(getScroll(div, false)).toBe(200); + scrollToSpy.mockRestore(); + }); +}); diff --git a/components/_util/__tests__/getScrollNode.test.ts b/components/_util/__tests__/getScrollNode.test.ts new file mode 100644 index 0000000..94faad8 --- /dev/null +++ b/components/_util/__tests__/getScrollNode.test.ts @@ -0,0 +1,9 @@ +/** @jest-environment node */ +import getScroll from '../getScroll'; + +describe('getScroll node', () => { + it('getScroll return 0 in node environment', async () => { + expect(getScroll(null, true)).toBe(0); + expect(getScroll(null, false)).toBe(0); + }); +}); diff --git a/components/_util/__tests__/reactNode.test.tsx b/components/_util/__tests__/reactNode.test.tsx new file mode 100644 index 0000000..dc2cad7 --- /dev/null +++ b/components/_util/__tests__/reactNode.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { isValidElement, cloneElement, isFragment, replaceElement } from '../reactNode'; + +describe('reactNode test', () => { + it('isValidElement', () => { + expect(isValidElement(null)).toBe(false); + expect(isValidElement(

test

)).toBe(true); + }); + it('isFragment', () => { + expect(isFragment(

test

)).toBe(false); + expect(isFragment(<>test)).toBe(true); + }); + it('replaceElement', () => { + const node =

test

; + expect(replaceElement(null, node)).toBe(node); + expect(replaceElement(node, node)).toStrictEqual(node); + }); + it('cloneElement', () => { + const node =

test

; + expect(cloneElement(null)).toBe(null); + expect(cloneElement(node)).toStrictEqual(node); + }); +}); diff --git a/components/_util/__tests__/responsiveObserve.test.ts b/components/_util/__tests__/responsiveObserve.test.ts new file mode 100644 index 0000000..6ab86ec --- /dev/null +++ b/components/_util/__tests__/responsiveObserve.test.ts @@ -0,0 +1,14 @@ +import ResponsiveObserve, { responsiveMap } from '../responsiveObserve'; + +describe('Test ResponsiveObserve', () => { + it('test ResponsiveObserve subscribe and unsubscribe', () => { + const { xs } = responsiveMap; + const subscribeFunc = jest.fn(); + const token = ResponsiveObserve.subscribe(subscribeFunc); + expect(ResponsiveObserve.matchHandlers[xs].mql.matches).toBeTruthy(); + expect(subscribeFunc).toHaveBeenCalledTimes(1); + + ResponsiveObserve.unsubscribe(token); + expect(ResponsiveObserve.matchHandlers[xs].mql.removeListener).toHaveBeenCalled(); + }); +}); diff --git a/components/_util/__tests__/scrollTo.test.ts b/components/_util/__tests__/scrollTo.test.ts new file mode 100644 index 0000000..5fe2e09 --- /dev/null +++ b/components/_util/__tests__/scrollTo.test.ts @@ -0,0 +1,72 @@ +import { waitFakeTimer } from '../../../tests/utils'; +import scrollTo from '../scrollTo'; + +describe('Test ScrollTo function', () => { + const dateNowMock = jest.spyOn(Date, 'now'); + + beforeAll(() => { + jest.useFakeTimers(); + }); + + beforeEach(() => { + dateNowMock.mockReturnValueOnce(0).mockReturnValueOnce(1000); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + dateNowMock.mockClear(); + }); + + it('test scrollTo', async () => { + const scrollToSpy = jest.spyOn(window, 'scrollTo').mockImplementation((_, y) => { + window.scrollY = y; + window.pageYOffset = y; + }); + + scrollTo(1000); + await waitFakeTimer(); + + expect(window.pageYOffset).toBe(1000); + + scrollToSpy.mockRestore(); + }); + + it('test callback - option', async () => { + const cbMock = jest.fn(); + scrollTo(1000, { + callback: cbMock, + }); + await waitFakeTimer(); + expect(cbMock).toHaveBeenCalledTimes(1); + }); + + it('test getContainer - option', async () => { + const div = document.createElement('div'); + scrollTo(1000, { + getContainer: () => div, + }); + await waitFakeTimer(); + expect(div.scrollTop).toBe(1000); + }); + + it('test getContainer document - option', async () => { + scrollTo(1000, { + getContainer: () => document, + }); + await waitFakeTimer(); + expect(document.documentElement.scrollTop).toBe(1000); + }); + + it('test duration - option', async () => { + scrollTo(1000, { + duration: 1100, + getContainer: () => document, + }); + await waitFakeTimer(); + expect(document.documentElement.scrollTop).toBe(1000); + }); +}); diff --git a/components/_util/__tests__/transButton.test.tsx b/components/_util/__tests__/transButton.test.tsx new file mode 100644 index 0000000..6f1fef1 --- /dev/null +++ b/components/_util/__tests__/transButton.test.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import TransButton from '../transButton'; +import { render } from '../../../tests/utils'; + +describe('transButton component', () => { + it('disabled should update style', () => { + const { container } = render(); + expect(container.querySelector('div')?.style.pointerEvents).toBe('none'); + }); +}); diff --git a/components/_util/__tests__/useSyncState.test.tsx b/components/_util/__tests__/useSyncState.test.tsx new file mode 100644 index 0000000..b0c0d96 --- /dev/null +++ b/components/_util/__tests__/useSyncState.test.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import useSyncState from '../hooks/useSyncState'; +import { render, fireEvent } from '../../../tests/utils'; + +describe('Table', () => { + it('useSyncState', () => { + const Test = () => { + const [getVal, setVal] = useSyncState('light'); + return setVal('bamboo')}>{getVal()}; + }; + + const { container } = render(); + expect(container.querySelector('span')?.innerHTML).toBe('light'); + fireEvent.click(container.querySelector('span')!); + expect(container.querySelector('span')?.innerHTML).toBe('bamboo'); + }); +}); diff --git a/components/_util/__tests__/util.test.tsx b/components/_util/__tests__/util.test.tsx new file mode 100644 index 0000000..419cc28 --- /dev/null +++ b/components/_util/__tests__/util.test.tsx @@ -0,0 +1,191 @@ +/* eslint-disable class-methods-use-this */ +import KeyCode from 'rc-util/lib/KeyCode'; +import raf from 'rc-util/lib/raf'; +import React from 'react'; +import { waitFakeTimer, render, fireEvent } from '../../../tests/utils'; +import getDataOrAriaProps from '../getDataOrAriaProps'; +import delayRaf from '../raf'; +import { isStyleSupport } from '../styleChecker'; +import { + throttleByAnimationFrame, + throttleByAnimationFrameDecorator, +} from '../throttleByAnimationFrame'; +import TransButton from '../transButton'; + +describe('Test utils function', () => { + describe('throttle', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('throttle function should work', async () => { + const callback = jest.fn(); + const throttled = throttleByAnimationFrame(callback); + expect(callback).not.toHaveBeenCalled(); + + throttled(); + throttled(); + await waitFakeTimer(); + + expect(callback).toHaveBeenCalled(); + expect(callback.mock.calls.length).toBe(1); + }); + + it('throttle function should be canceled', async () => { + const callback = jest.fn(); + const throttled = throttleByAnimationFrame(callback); + + throttled(); + throttled.cancel(); + await waitFakeTimer(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('throttleByAnimationFrameDecorator should works', async () => { + const callbackFn = jest.fn(); + class Test { + @throttleByAnimationFrameDecorator() + callback() { + callbackFn(); + } + } + const test = new Test(); + test.callback(); + test.callback(); + test.callback(); + await waitFakeTimer(); + expect(callbackFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('getDataOrAriaProps', () => { + it('returns all data-* properties from an object', () => { + const props = { + onClick: () => {}, + isOpen: true, + 'data-test': 'test-id', + 'data-id': 1234, + }; + const results = getDataOrAriaProps(props); + expect(results).toEqual({ + 'data-test': 'test-id', + 'data-id': 1234, + }); + }); + + it('does not return data-__ properties from an object', () => { + const props = { + onClick: () => {}, + isOpen: true, + 'data-__test': 'test-id', + 'data-__id': 1234, + }; + const results = getDataOrAriaProps(props); + expect(results).toEqual({}); + }); + + it('returns all aria-* properties from an object', () => { + const props = { + onClick: () => {}, + isOpen: true, + 'aria-labelledby': 'label-id', + 'aria-label': 'some-label', + }; + const results = getDataOrAriaProps(props); + expect(results).toEqual({ + 'aria-labelledby': 'label-id', + 'aria-label': 'some-label', + }); + }); + + it('returns role property from an object', () => { + const props = { + onClick: () => {}, + isOpen: true, + role: 'search', + }; + const results = getDataOrAriaProps(props); + expect(results).toEqual({ role: 'search' }); + }); + }); + + it('delayRaf', done => { + jest.useRealTimers(); + + let bamboo = false; + delayRaf(() => { + bamboo = true; + }, 3); + + // Do nothing, but insert in the frame + // https://github.com/ant-design/ant-design/issues/16290 + delayRaf(() => {}, 3); + + // Variable bamboo should be false in frame 2 but true in frame 4 + raf(() => { + expect(bamboo).toBe(false); + + // Frame 2 + raf(() => { + expect(bamboo).toBe(false); + + // Frame 3 + raf(() => { + // Frame 4 + raf(() => { + expect(bamboo).toBe(true); + done(); + }); + }); + }); + }); + }); + + describe('TransButton', () => { + it('can be focus/blur', () => { + const ref = React.createRef(); + render(TransButton); + expect(typeof ref.current?.focus).toBe('function'); + expect(typeof ref.current?.blur).toBe('function'); + }); + + it('should trigger onClick when press enter', () => { + const onClick = jest.fn(); + + const { container } = render(TransButton); + + // callback should trigger + fireEvent.keyUp(container.querySelector('div')!, { keyCode: KeyCode.ENTER }); + expect(onClick).toHaveBeenCalledTimes(1); + + // callback should not trigger + fireEvent.keyDown(container.querySelector('div')!, { keyCode: KeyCode.ENTER }); + expect(onClick).toHaveBeenCalledTimes(1); + }); + }); + + describe('style', () => { + it('isStyleSupport', () => { + expect(isStyleSupport('color')).toBe(true); + expect(isStyleSupport('not-existed')).toBe(false); + }); + + it('isStyleSupport return false in service side', () => { + const spy = jest + .spyOn(window.document, 'documentElement', 'get') + .mockImplementation(() => undefined as unknown as HTMLElement); + expect(isStyleSupport('color')).toBe(false); + expect(isStyleSupport('not-existed')).toBe(false); + spy.mockRestore(); + }); + }); +}); diff --git a/components/_util/__tests__/warning.test.ts b/components/_util/__tests__/warning.test.ts new file mode 100644 index 0000000..e02e8ef --- /dev/null +++ b/components/_util/__tests__/warning.test.ts @@ -0,0 +1,63 @@ +describe('Test warning', () => { + let spy: jest.SpyInstance; + + beforeAll(() => { + spy = jest.spyOn(console, 'error'); + }); + + afterAll(() => { + spy.mockRestore(); + }); + + beforeEach(() => { + jest.resetModules(); + }); + + afterEach(() => { + spy.mockReset(); + }); + + it('Test noop', async () => { + const { noop } = await import('../warning'); + const value = noop(); + + expect(value).toBe(undefined); + expect(spy).not.toHaveBeenCalled(); + expect(noop).not.toThrow(); + }); + + describe('process.env.NODE_ENV !== "production"', () => { + it('If `false`, exec `console.error`', async () => { + const warning = (await import('../warning')).default; + warning(false, 'error'); + + expect(spy).toHaveBeenCalled(); + }); + + it('If `true`, do not exec `console.error`', async () => { + const warning = (await import('../warning')).default; + warning(true, 'error message'); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('process.env.NODE_ENV === "production"', () => { + it('Whether `true` or `false`, do not exec `console.error`', async () => { + const prevEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const { default: warning, noop } = await import('../warning'); + + expect(warning).toEqual(noop); + + warning(false, 'error message'); + expect(spy).not.toHaveBeenCalled(); + + warning(true, 'error message'); + expect(spy).not.toHaveBeenCalled(); + + process.env.NODE_ENV = prevEnv; + }); + }); +}); diff --git a/components/_util/__tests__/wave.test.tsx b/components/_util/__tests__/wave.test.tsx new file mode 100644 index 0000000..e23cf93 --- /dev/null +++ b/components/_util/__tests__/wave.test.tsx @@ -0,0 +1,255 @@ +import React from 'react'; +import mountTest from '../../../tests/shared/mountTest'; +import { render, waitFakeTimer, fireEvent, act } from '../../../tests/utils'; +import ConfigProvider from '../../config-provider'; +import Wave from '../wave'; + +describe('Wave component', () => { + mountTest(Wave); + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + const styles = document.getElementsByTagName('style'); + for (let i = 0; i < styles.length; i += 1) { + styles[i].remove(); + } + }); + + it('isHidden works', () => { + const TEST_NODE_ENV = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + const { container, unmount } = render( + + + , + ); + expect(container.querySelector('button')?.className).toBe(''); + + container.querySelector('button')?.click(); + + expect( + container.querySelector('button')?.hasAttribute('ant-click-animating-without-extra-node'), + ).toBeFalsy(); + unmount(); + process.env.NODE_ENV = TEST_NODE_ENV; + }); + + it('isHidden is mocked', () => { + const { container, unmount } = render( + + + , + ); + expect(container.querySelector('button')?.className).toBe(''); + container.querySelector('button')?.click(); + expect( + container.querySelector('button')?.getAttribute('ant-click-animating-without-extra-node'), + ).toBe('false'); + unmount(); + }); + + it('wave color is grey', async () => { + const { container, unmount } = render( + + + , + ); + container.querySelector('button')?.click(); + await waitFakeTimer(); + const styles = ( + container.querySelector('button')?.getRootNode() as HTMLButtonElement + ).getElementsByTagName('style'); + expect(styles.length).toBe(0); + unmount(); + }); + + it('wave color is not grey', async () => { + const { container, unmount } = render( + + + , + ); + container.querySelector('button')?.click(); + await waitFakeTimer(); + const styles = ( + container.querySelector('button')?.getRootNode() as HTMLButtonElement + ).getElementsByTagName('style'); + expect(styles.length).toBe(1); + expect(styles[0].innerHTML).toContain('--antd-wave-shadow-color: red;'); + unmount(); + }); + + it('read wave color from border-top-color', async () => { + const { container, unmount } = render( + +
button
+
, + ); + container.querySelector('div')?.click(); + await waitFakeTimer(); + const styles = ( + container.querySelector('div')?.getRootNode() as HTMLDivElement + ).getElementsByTagName('style'); + expect(styles.length).toBe(1); + expect(styles[0].innerHTML).toContain('--antd-wave-shadow-color: blue;'); + unmount(); + }); + + it('read wave color from background color', async () => { + const { container, unmount } = render( + +
button
+
, + ); + container.querySelector('div')?.click(); + await waitFakeTimer(); + const styles = ( + container.querySelector('div')?.getRootNode() as HTMLDivElement + ).getElementsByTagName('style'); + expect(styles.length).toBe(1); + expect(styles[0].innerHTML).toContain('--antd-wave-shadow-color: green;'); + unmount(); + }); + + it('read wave color from border firstly', async () => { + const { container, unmount } = render( + +
button
+
, + ); + container.querySelector('div')?.click(); + await waitFakeTimer(); + const styles = ( + container.querySelector('div')?.getRootNode() as HTMLDivElement + ).getElementsByTagName('style'); + expect(styles.length).toBe(1); + expect(styles[0].innerHTML).toContain('--antd-wave-shadow-color: yellow;'); + unmount(); + }); + + it('hidden element with -leave className', async () => { + const { container, unmount } = render( + + + , + ); + container.querySelector('button')?.click(); + await waitFakeTimer(); + const styles = ( + container.querySelector('button')?.getRootNode() as HTMLButtonElement + ).getElementsByTagName('style'); + expect(styles.length).toBe(0); + unmount(); + }); + + it('ConfigProvider csp', async () => { + const { container, unmount } = render( + + + + + , + ); + container.querySelector('button')?.click(); + await waitFakeTimer(); + const styles = ( + container.querySelector('button')?.getRootNode() as HTMLButtonElement + ).getElementsByTagName('style'); + expect(styles[0].getAttribute('nonce')).toBe('YourNonceCode'); + unmount(); + }); + + it('bindAnimationEvent should return when node is null', () => { + const ref = React.createRef(); + render( + + + , + ); + expect(ref.current?.bindAnimationEvent()).toBe(undefined); + }); + + it('bindAnimationEvent.onClick should return when children is hidden', () => { + const ref = React.createRef(); + render( + + + , + ); + expect(ref.current?.bindAnimationEvent()).toBe(undefined); + }); + + it('bindAnimationEvent.onClick should return when children is input', () => { + const ref = React.createRef(); + render( + + + , + ); + expect(ref.current?.bindAnimationEvent()).toBe(undefined); + }); + + it('should not throw when click it', () => { + expect(() => { + const { container } = render( + +
+ , + ); + fireEvent.click(container); + }).not.toThrow(); + }); + + it('should not throw when no children', () => { + expect(() => render()).not.toThrow(); + }); + + it('Wave style should append to validate element', () => { + jest.useFakeTimers(); + const { container } = render( + +
+ , + ); + + // Mock shadow container + const fakeDoc = document.createElement('div'); + fakeDoc.append('text'); + fakeDoc.appendChild(document.createElement('span')); + expect(fakeDoc.childNodes).toHaveLength(2); + + const elem = container.querySelector('.bamboo'); + + if (elem) { + elem.getRootNode = () => fakeDoc; + + // Click should not throw + fireEvent.click(elem); + act(() => { + jest.runAllTimers(); + }); + + expect(fakeDoc.querySelector('style')).toBeTruthy(); + } + + jest.useRealTimers(); + }); +}); diff --git a/components/_util/colors.ts b/components/_util/colors.ts new file mode 100644 index 0000000..da8a36a --- /dev/null +++ b/components/_util/colors.ts @@ -0,0 +1,23 @@ +import type { ElementOf } from './type'; +import { tuple } from './type'; + +export const PresetStatusColorTypes = tuple('success', 'processing', 'error', 'default', 'warning'); +// eslint-disable-next-line import/prefer-default-export +export const PresetColorTypes = tuple( + 'pink', + 'red', + 'yellow', + 'orange', + 'cyan', + 'green', + 'blue', + 'purple', + 'geekblue', + 'magenta', + 'volcano', + 'gold', + 'lime', +); + +export type PresetColorType = ElementOf; +export type PresetStatusColorType = ElementOf; diff --git a/components/_util/easings.ts b/components/_util/easings.ts new file mode 100644 index 0000000..594758a --- /dev/null +++ b/components/_util/easings.ts @@ -0,0 +1,10 @@ +// eslint-disable-next-line import/prefer-default-export +export function easeInOutCubic(t: number, b: number, c: number, d: number) { + const cc = c - b; + t /= d / 2; + if (t < 1) { + return (cc / 2) * t * t * t + b; + } + // eslint-disable-next-line no-return-assign + return (cc / 2) * ((t -= 2) * t * t + 2) + b; +} diff --git a/components/_util/getDataOrAriaProps.ts b/components/_util/getDataOrAriaProps.ts new file mode 100644 index 0000000..1984714 --- /dev/null +++ b/components/_util/getDataOrAriaProps.ts @@ -0,0 +1,11 @@ +export default function getDataOrAriaProps(props: any) { + return Object.keys(props).reduce((prev: any, key: string) => { + if ( + (key.startsWith('data-') || key.startsWith('aria-') || key === 'role') && + !key.startsWith('data-__') + ) { + prev[key] = props[key]; + } + return prev; + }, {}); +} diff --git a/components/_util/getRenderPropValue.ts b/components/_util/getRenderPropValue.ts new file mode 100644 index 0000000..fe67316 --- /dev/null +++ b/components/_util/getRenderPropValue.ts @@ -0,0 +1,17 @@ +import type * as React from 'react'; + +export type RenderFunction = () => React.ReactNode; + +export const getRenderPropValue = ( + propValue?: React.ReactNode | RenderFunction, +): React.ReactNode => { + if (!propValue) { + return null; + } + + if (typeof propValue === 'function') { + return propValue(); + } + + return propValue; +}; diff --git a/components/_util/getScroll.tsx b/components/_util/getScroll.tsx new file mode 100644 index 0000000..f861b3a --- /dev/null +++ b/components/_util/getScroll.tsx @@ -0,0 +1,33 @@ +export function isWindow(obj: any): obj is Window { + return obj !== null && obj !== undefined && obj === obj.window; +} + +export default function getScroll( + target: HTMLElement | Window | Document | null, + top: boolean, +): number { + if (typeof window === 'undefined') { + return 0; + } + const method = top ? 'scrollTop' : 'scrollLeft'; + let result = 0; + if (isWindow(target)) { + result = target[top ? 'pageYOffset' : 'pageXOffset']; + } else if (target instanceof Document) { + result = target.documentElement[method]; + } else if (target instanceof HTMLElement) { + result = target[method]; + } else if (target) { + // According to the type inference, the `target` is `never` type. + // Since we configured the loose mode type checking, and supports mocking the target with such shape below:: + // `{ documentElement: { scrollLeft: 200, scrollTop: 400 } }`, + // the program may falls into this branch. + // Check the corresponding tests for details. Don't sure what is the real scenario this happens. + result = target[method]; + } + + if (target && !isWindow(target) && typeof result !== 'number') { + result = (target.ownerDocument ?? target).documentElement?.[method]; + } + return result; +} diff --git a/components/_util/hooks/useFlexGapSupport.ts b/components/_util/hooks/useFlexGapSupport.ts new file mode 100644 index 0000000..b0640f0 --- /dev/null +++ b/components/_util/hooks/useFlexGapSupport.ts @@ -0,0 +1,11 @@ +import * as React from 'react'; +import { detectFlexGapSupported } from '../styleChecker'; + +export default () => { + const [flexible, setFlexible] = React.useState(false); + React.useEffect(() => { + setFlexible(detectFlexGapSupported()); + }, []); + + return flexible; +}; diff --git a/components/_util/hooks/useForceUpdate.ts b/components/_util/hooks/useForceUpdate.ts new file mode 100644 index 0000000..4b6f49c --- /dev/null +++ b/components/_util/hooks/useForceUpdate.ts @@ -0,0 +1,6 @@ +import * as React from 'react'; + +export default function useForceUpdate() { + const [, forceUpdate] = React.useReducer(x => x + 1, 0); + return forceUpdate; +} diff --git a/components/_util/hooks/usePatchElement.tsx b/components/_util/hooks/usePatchElement.tsx new file mode 100644 index 0000000..fb75f15 --- /dev/null +++ b/components/_util/hooks/usePatchElement.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +export default function usePatchElement(): [ + React.ReactElement[], + (element: React.ReactElement) => Function, +] { + const [elements, setElements] = React.useState([]); + + const patchElement = React.useCallback((element: React.ReactElement) => { + // append a new element to elements (and create a new ref) + setElements(originElements => [...originElements, element]); + + // return a function that removes the new element out of elements (and create a new ref) + // it works a little like useEffect + return () => { + setElements(originElements => originElements.filter(ele => ele !== element)); + }; + }, []); + + return [elements, patchElement]; +} diff --git a/components/_util/hooks/useSyncState.ts b/components/_util/hooks/useSyncState.ts new file mode 100644 index 0000000..03fcb47 --- /dev/null +++ b/components/_util/hooks/useSyncState.ts @@ -0,0 +1,18 @@ +import * as React from 'react'; +import useForceUpdate from './useForceUpdate'; + +type UseSyncStateProps = readonly [() => T, (newValue: T) => void]; + +export default function useSyncState(initialValue: T): UseSyncStateProps { + const ref = React.useRef(initialValue); + const forceUpdate = useForceUpdate(); + + return [ + () => ref.current, + (newValue: T) => { + ref.current = newValue; + // re-render + forceUpdate(); + }, + ] as const; +} diff --git a/components/_util/isNumeric.ts b/components/_util/isNumeric.ts new file mode 100644 index 0000000..b1aeed2 --- /dev/null +++ b/components/_util/isNumeric.ts @@ -0,0 +1,3 @@ +const isNumeric = (value: any): boolean => !isNaN(parseFloat(value)) && isFinite(value); + +export default isNumeric; diff --git a/components/_util/motion.tsx b/components/_util/motion.tsx new file mode 100644 index 0000000..f4333ac --- /dev/null +++ b/components/_util/motion.tsx @@ -0,0 +1,46 @@ +import type { CSSMotionProps, MotionEndEventHandler, MotionEventHandler } from 'rc-motion'; +import type { MotionEvent } from 'rc-motion/lib/interface'; +import { tuple } from './type'; + +// ================== Collapse Motion ================== +const getCollapsedHeight: MotionEventHandler = () => ({ height: 0, opacity: 0 }); +const getRealHeight: MotionEventHandler = node => { + const { scrollHeight } = node; + return { height: scrollHeight, opacity: 1 }; +}; +const getCurrentHeight: MotionEventHandler = node => ({ height: node ? node.offsetHeight : 0 }); +const skipOpacityTransition: MotionEndEventHandler = (_, event: MotionEvent) => + event?.deadline === true || (event as TransitionEvent).propertyName === 'height'; + +const collapseMotion: CSSMotionProps = { + motionName: 'ant-motion-collapse', + onAppearStart: getCollapsedHeight, + onEnterStart: getCollapsedHeight, + onAppearActive: getRealHeight, + onEnterActive: getRealHeight, + onLeaveStart: getCurrentHeight, + onLeaveActive: getCollapsedHeight, + onAppearEnd: skipOpacityTransition, + onEnterEnd: skipOpacityTransition, + onLeaveEnd: skipOpacityTransition, + motionDeadline: 500, +}; + +const SelectPlacements = tuple('bottomLeft', 'bottomRight', 'topLeft', 'topRight'); +export type SelectCommonPlacement = typeof SelectPlacements[number]; + +const getTransitionDirection = (placement: SelectCommonPlacement | undefined) => { + if (placement !== undefined && (placement === 'topLeft' || placement === 'topRight')) { + return `slide-down`; + } + return `slide-up`; +}; + +const getTransitionName = (rootPrefixCls: string, motion: string, transitionName?: string) => { + if (transitionName !== undefined) { + return transitionName; + } + return `${rootPrefixCls}-${motion}`; +}; +export { getTransitionName, getTransitionDirection }; +export default collapseMotion; diff --git a/components/_util/placements.tsx b/components/_util/placements.tsx new file mode 100644 index 0000000..b1a5a1c --- /dev/null +++ b/components/_util/placements.tsx @@ -0,0 +1,112 @@ +import { placements } from 'rc-tooltip/lib/placements'; +import type { BuildInPlacements } from 'rc-trigger'; + +const autoAdjustOverflowEnabled = { + adjustX: 1, + adjustY: 1, +}; + +const autoAdjustOverflowDisabled = { + adjustX: 0, + adjustY: 0, +}; + +const targetOffset = [0, 0]; + +export interface AdjustOverflow { + adjustX?: 0 | 1; + adjustY?: 0 | 1; +} + +export interface PlacementsConfig { + arrowWidth?: number; + horizontalArrowShift?: number; + verticalArrowShift?: number; + arrowPointAtCenter?: boolean; + autoAdjustOverflow?: boolean | AdjustOverflow; +} + +export function getOverflowOptions(autoAdjustOverflow?: boolean | AdjustOverflow) { + if (typeof autoAdjustOverflow === 'boolean') { + return autoAdjustOverflow ? autoAdjustOverflowEnabled : autoAdjustOverflowDisabled; + } + return { + ...autoAdjustOverflowDisabled, + ...autoAdjustOverflow, + }; +} + +export default function getPlacements(config: PlacementsConfig) { + const { + arrowWidth = 4, + horizontalArrowShift = 16, + verticalArrowShift = 8, + autoAdjustOverflow, + arrowPointAtCenter, + } = config; + const placementMap: BuildInPlacements = { + left: { + points: ['cr', 'cl'], + offset: [-4, 0], + }, + right: { + points: ['cl', 'cr'], + offset: [4, 0], + }, + top: { + points: ['bc', 'tc'], + offset: [0, -4], + }, + bottom: { + points: ['tc', 'bc'], + offset: [0, 4], + }, + topLeft: { + points: ['bl', 'tc'], + offset: [-(horizontalArrowShift + arrowWidth), -4], + }, + leftTop: { + points: ['tr', 'cl'], + offset: [-4, -(verticalArrowShift + arrowWidth)], + }, + topRight: { + points: ['br', 'tc'], + offset: [horizontalArrowShift + arrowWidth, -4], + }, + rightTop: { + points: ['tl', 'cr'], + offset: [4, -(verticalArrowShift + arrowWidth)], + }, + bottomRight: { + points: ['tr', 'bc'], + offset: [horizontalArrowShift + arrowWidth, 4], + }, + rightBottom: { + points: ['bl', 'cr'], + offset: [4, verticalArrowShift + arrowWidth], + }, + bottomLeft: { + points: ['tl', 'bc'], + offset: [-(horizontalArrowShift + arrowWidth), 4], + }, + leftBottom: { + points: ['br', 'cl'], + offset: [-4, verticalArrowShift + arrowWidth], + }, + }; + Object.keys(placementMap).forEach(key => { + placementMap[key] = arrowPointAtCenter + ? { + ...placementMap[key], + overflow: getOverflowOptions(autoAdjustOverflow), + targetOffset, + } + : { + ...placements[key], + overflow: getOverflowOptions(autoAdjustOverflow), + }; + + placementMap[key].ignoreShake = true; + }); + return placementMap; +} diff --git a/components/_util/raf.ts b/components/_util/raf.ts new file mode 100644 index 0000000..8954b62 --- /dev/null +++ b/components/_util/raf.ts @@ -0,0 +1,38 @@ +import raf from 'rc-util/lib/raf'; + +interface RafMap { + [id: number]: number; +} + +let id: number = 0; +const ids: RafMap = {}; + +// Support call raf with delay specified frame +export default function wrapperRaf(callback: () => void, delayFrames: number = 1): number { + const myId: number = id++; + let restFrames: number = delayFrames; + + function internalCallback() { + restFrames -= 1; + + if (restFrames <= 0) { + callback(); + delete ids[myId]; + } else { + ids[myId] = raf(internalCallback); + } + } + + ids[myId] = raf(internalCallback); + + return myId; +} + +wrapperRaf.cancel = function cancel(pid?: number) { + if (pid === undefined) return; + + raf.cancel(ids[pid]); + delete ids[pid]; +}; + +wrapperRaf.ids = ids; // export this for test usage diff --git a/components/_util/reactNode.ts b/components/_util/reactNode.ts new file mode 100644 index 0000000..8e5b73c --- /dev/null +++ b/components/_util/reactNode.ts @@ -0,0 +1,29 @@ +import * as React from 'react'; + +export const { isValidElement } = React; + +export function isFragment(child: any): boolean { + return child && isValidElement(child) && child.type === React.Fragment; +} + +type AnyObject = Record; + +type RenderProps = AnyObject | ((originProps: AnyObject) => AnyObject | void); + +export function replaceElement( + element: React.ReactNode, + replacement: React.ReactNode, + props?: RenderProps, +): React.ReactNode { + if (!isValidElement(element)) { + return replacement; + } + return React.cloneElement( + element, + typeof props === 'function' ? props(element.props || {}) : props, + ); +} + +export function cloneElement(element: React.ReactNode, props?: RenderProps): React.ReactElement { + return replaceElement(element, element, props) as React.ReactElement; +} diff --git a/components/_util/responsiveObserve.ts b/components/_util/responsiveObserve.ts new file mode 100644 index 0000000..65d5356 --- /dev/null +++ b/components/_util/responsiveObserve.ts @@ -0,0 +1,74 @@ +export type Breakpoint = 'xxl' | 'xl' | 'lg' | 'md' | 'sm' | 'xs'; +export type BreakpointMap = Record; +export type ScreenMap = Partial>; +export type ScreenSizeMap = Partial>; + +export const responsiveArray: Breakpoint[] = ['xxl', 'xl', 'lg', 'md', 'sm', 'xs']; + +export const responsiveMap: BreakpointMap = { + xs: '(max-width: 575px)', + sm: '(min-width: 576px)', + md: '(min-width: 768px)', + lg: '(min-width: 992px)', + xl: '(min-width: 1200px)', + xxl: '(min-width: 1600px)', +}; + +type SubscribeFunc = (screens: ScreenMap) => void; +const subscribers = new Map(); +let subUid = -1; +let screens = {}; + +const responsiveObserve = { + matchHandlers: {} as { + [prop: string]: { + mql: MediaQueryList; + listener: ((this: MediaQueryList, ev: MediaQueryListEvent) => any) | null; + }; + }, + dispatch(pointMap: ScreenMap) { + screens = pointMap; + subscribers.forEach(func => func(screens)); + return subscribers.size >= 1; + }, + subscribe(func: SubscribeFunc): number { + if (!subscribers.size) this.register(); + subUid += 1; + subscribers.set(subUid, func); + func(screens); + return subUid; + }, + unsubscribe(token: number) { + subscribers.delete(token); + if (!subscribers.size) this.unregister(); + }, + unregister() { + Object.keys(responsiveMap).forEach((screen: Breakpoint) => { + const matchMediaQuery = responsiveMap[screen]; + const handler = this.matchHandlers[matchMediaQuery]; + handler?.mql.removeListener(handler?.listener); + }); + subscribers.clear(); + }, + register() { + Object.keys(responsiveMap).forEach((screen: Breakpoint) => { + const matchMediaQuery = responsiveMap[screen]; + const listener = ({ matches }: { matches: boolean }) => { + this.dispatch({ + ...screens, + [screen]: matches, + }); + }; + const mql = window.matchMedia(matchMediaQuery); + mql.addListener(listener); + this.matchHandlers[matchMediaQuery] = { + mql, + listener, + }; + + listener(mql); + }); + }, +}; + +export default responsiveObserve; diff --git a/components/_util/scrollTo.ts b/components/_util/scrollTo.ts new file mode 100644 index 0000000..1197fc4 --- /dev/null +++ b/components/_util/scrollTo.ts @@ -0,0 +1,38 @@ +import raf from 'rc-util/lib/raf'; +import { easeInOutCubic } from './easings'; +import getScroll, { isWindow } from './getScroll'; + +interface ScrollToOptions { + /** Scroll container, default as window */ + getContainer?: () => HTMLElement | Window | Document; + /** Scroll end callback */ + callback?: () => any; + /** Animation duration, default as 450 */ + duration?: number; +} + +export default function scrollTo(y: number, options: ScrollToOptions = {}) { + const { getContainer = () => window, callback, duration = 450 } = options; + const container = getContainer(); + const scrollTop = getScroll(container, true); + const startTime = Date.now(); + + const frameFunc = () => { + const timestamp = Date.now(); + const time = timestamp - startTime; + const nextScrollTop = easeInOutCubic(time > duration ? duration : time, scrollTop, y, duration); + if (isWindow(container)) { + (container as Window).scrollTo(window.pageXOffset, nextScrollTop); + } else if (container instanceof Document || container.constructor.name === 'HTMLDocument') { + (container as Document).documentElement.scrollTop = nextScrollTop; + } else { + (container as HTMLElement).scrollTop = nextScrollTop; + } + if (time < duration) { + raf(frameFunc); + } else if (typeof callback === 'function') { + callback(); + } + }; + raf(frameFunc); +} diff --git a/components/_util/statusUtils.tsx b/components/_util/statusUtils.tsx new file mode 100644 index 0000000..b6a6d42 --- /dev/null +++ b/components/_util/statusUtils.tsx @@ -0,0 +1,23 @@ +import classNames from 'classnames'; +import type { ValidateStatus } from '../form/FormItem'; +import { tuple } from './type'; + +const InputStatuses = tuple('warning', 'error', ''); +export type InputStatus = typeof InputStatuses[number]; + +export function getStatusClassNames( + prefixCls: string, + status?: ValidateStatus, + hasFeedback?: boolean, +) { + return classNames({ + [`${prefixCls}-status-success`]: status === 'success', + [`${prefixCls}-status-warning`]: status === 'warning', + [`${prefixCls}-status-error`]: status === 'error', + [`${prefixCls}-status-validating`]: status === 'validating', + [`${prefixCls}-has-feedback`]: hasFeedback, + }); +} + +export const getMergedStatus = (contextStatus?: ValidateStatus, customStatus?: InputStatus) => + customStatus || contextStatus; diff --git a/components/_util/styleChecker.tsx b/components/_util/styleChecker.tsx new file mode 100644 index 0000000..0c291d4 --- /dev/null +++ b/components/_util/styleChecker.tsx @@ -0,0 +1,34 @@ +import canUseDom from 'rc-util/lib/Dom/canUseDom'; +import { isStyleSupport } from 'rc-util/lib/Dom/styleChecker'; + +export const canUseDocElement = () => canUseDom() && window.document.documentElement; + +export { isStyleSupport }; + +let flexGapSupported: boolean | undefined; +export const detectFlexGapSupported = () => { + if (!canUseDocElement()) { + return false; + } + + if (flexGapSupported !== undefined) { + return flexGapSupported; + } + + // create flex container with row-gap set + const flex = document.createElement('div'); + flex.style.display = 'flex'; + flex.style.flexDirection = 'column'; + flex.style.rowGap = '1px'; + + // create two, elements inside it + flex.appendChild(document.createElement('div')); + flex.appendChild(document.createElement('div')); + + // append to the DOM (needed to obtain scrollHeight) + document.body.appendChild(flex); + flexGapSupported = flex.scrollHeight === 1; // flex container should be 1px high from the row-gap + document.body.removeChild(flex); + + return flexGapSupported; +}; diff --git a/components/_util/throttleByAnimationFrame.tsx b/components/_util/throttleByAnimationFrame.tsx new file mode 100644 index 0000000..c04923c --- /dev/null +++ b/components/_util/throttleByAnimationFrame.tsx @@ -0,0 +1,56 @@ +import raf from 'rc-util/lib/raf'; + +export function throttleByAnimationFrame(fn: (...args: T) => void) { + let requestId: number | null; + + const later = (args: T) => () => { + requestId = null; + fn(...args); + }; + + const throttled: { + (...args: T): void; + cancel: () => void; + } = (...args: T) => { + if (requestId == null) { + requestId = raf(later(args)); + } + }; + + throttled.cancel = () => { + raf.cancel(requestId!); + requestId = null; + }; + + return throttled; +} + +export function throttleByAnimationFrameDecorator() { + return function throttle(target: any, key: string, descriptor: any) { + const fn = descriptor.value; + let definingProperty = false; + return { + configurable: true, + get() { + // In IE11 calling Object.defineProperty has a side-effect of evaluating the + // getter for the property which is being replaced. This causes infinite + // recursion and an "Out of stack space" error. + // eslint-disable-next-line no-prototype-builtins + if (definingProperty || this === target.prototype || this.hasOwnProperty(key)) { + /* istanbul ignore next */ + return fn; + } + + const boundFn = throttleByAnimationFrame(fn.bind(this)); + definingProperty = true; + Object.defineProperty(this, key, { + value: boundFn, + configurable: true, + writable: true, + }); + definingProperty = false; + return boundFn; + }, + }; + }; +} diff --git a/components/_util/transButton.tsx b/components/_util/transButton.tsx new file mode 100644 index 0000000..dea1c70 --- /dev/null +++ b/components/_util/transButton.tsx @@ -0,0 +1,72 @@ +/** + * Wrap of sub component which need use as Button capacity (like Icon component). + * + * This helps accessibility reader to tread as a interactive button to operation. + */ +import KeyCode from 'rc-util/lib/KeyCode'; +import * as React from 'react'; + +interface TransButtonProps extends React.HTMLAttributes { + onClick?: (e?: React.MouseEvent) => void; + noStyle?: boolean; + autoFocus?: boolean; + disabled?: boolean; +} + +const inlineStyle: React.CSSProperties = { + border: 0, + background: 'transparent', + padding: 0, + lineHeight: 'inherit', + display: 'inline-block', +}; + +const TransButton = React.forwardRef((props, ref) => { + const onKeyDown: React.KeyboardEventHandler = event => { + const { keyCode } = event; + if (keyCode === KeyCode.ENTER) { + event.preventDefault(); + } + }; + + const onKeyUp: React.KeyboardEventHandler = event => { + const { keyCode } = event; + const { onClick } = props; + if (keyCode === KeyCode.ENTER && onClick) { + onClick(); + } + }; + + const { style, noStyle, disabled, ...restProps } = props; + + let mergedStyle: React.CSSProperties = {}; + + if (!noStyle) { + mergedStyle = { + ...inlineStyle, + }; + } + + if (disabled) { + mergedStyle.pointerEvents = 'none'; + } + + mergedStyle = { + ...mergedStyle, + ...style, + }; + + return ( +
+ ); +}); + +export default TransButton; diff --git a/components/_util/type.ts b/components/_util/type.ts new file mode 100644 index 0000000..a42c2b8 --- /dev/null +++ b/components/_util/type.ts @@ -0,0 +1,13 @@ +// https://stackoverflow.com/questions/46176165/ways-to-get-string-literal-type-of-array-values-without-enum-overhead +export const tuple = (...args: T) => args; + +export const tupleNum = (...args: T) => args; + +/** + * https://stackoverflow.com/a/59187769 Extract the type of an element of an array/tuple without + * performing indexing + */ +export type ElementOf = T extends (infer E)[] ? E : T extends readonly (infer F)[] ? F : never; + +/** https://github.com/Microsoft/TypeScript/issues/29729 */ +export type LiteralUnion = T | (U & {}); diff --git a/components/_util/warning.ts b/components/_util/warning.ts new file mode 100644 index 0000000..994ed46 --- /dev/null +++ b/components/_util/warning.ts @@ -0,0 +1,21 @@ +import rcWarning, { resetWarned } from 'rc-util/lib/warning'; + +export { resetWarned }; +export function noop() {} + +type Warning = (valid: boolean, component: string, message?: string) => void; + +// eslint-disable-next-line import/no-mutable-exports +let warning: Warning = noop; +if (process.env.NODE_ENV !== 'production') { + warning = (valid, component, message) => { + rcWarning(valid, `[antd: ${component}] ${message}`); + + // StrictMode will inject console which will not throw warning in React 17. + if (process.env.NODE_ENV === 'test') { + resetWarned(); + } + }; +} + +export default warning; diff --git a/components/_util/wave.tsx b/components/_util/wave.tsx new file mode 100644 index 0000000..90ab384 --- /dev/null +++ b/components/_util/wave.tsx @@ -0,0 +1,241 @@ +import { updateCSS } from 'rc-util/lib/Dom/dynamicCSS'; +import { composeRef, supportRef } from 'rc-util/lib/ref'; +import * as React from 'react'; +import type { ConfigConsumerProps, CSPConfig } from '../config-provider'; +import { ConfigConsumer, ConfigContext } from '../config-provider'; +import raf from './raf'; +import { cloneElement } from './reactNode'; + +let styleForPseudo: HTMLStyleElement | null; + +// Where el is the DOM element you'd like to test for visibility +function isHidden(element: HTMLElement) { + if (process.env.NODE_ENV === 'test') { + return false; + } + return !element || element.offsetParent === null || element.hidden; +} + +function getValidateContainer(nodeRoot: Node): Element { + if (nodeRoot instanceof Document) { + return nodeRoot.body; + } + + return Array.from(nodeRoot.childNodes).find( + ele => ele?.nodeType === Node.ELEMENT_NODE, + ) as Element; +} + +function isNotGrey(color: string) { + // eslint-disable-next-line no-useless-escape + const match = (color || '').match(/rgba?\((\d*), (\d*), (\d*)(, [\d.]*)?\)/); + if (match && match[1] && match[2] && match[3]) { + return !(match[1] === match[2] && match[2] === match[3]); + } + return true; +} + +export interface WaveProps { + insertExtraNode?: boolean; + disabled?: boolean; + children?: React.ReactNode; +} + +class Wave extends React.Component { + static contextType = ConfigContext; + + private instance?: { + cancel: () => void; + }; + + private containerRef = React.createRef(); + + private extraNode: HTMLDivElement; + + private clickWaveTimeoutId: number; + + private animationStartId: number; + + private animationStart: boolean = false; + + private destroyed: boolean = false; + + private csp?: CSPConfig; + + context: ConfigConsumerProps; + + componentDidMount() { + this.destroyed = false; + const node = this.containerRef.current as HTMLDivElement; + if (!node || node.nodeType !== 1) { + return; + } + this.instance = this.bindAnimationEvent(node); + } + + componentWillUnmount() { + if (this.instance) { + this.instance.cancel(); + } + if (this.clickWaveTimeoutId) { + clearTimeout(this.clickWaveTimeoutId); + } + + this.destroyed = true; + } + + onClick = (node: HTMLElement, waveColor: string) => { + const { insertExtraNode, disabled } = this.props; + + if (disabled || !node || isHidden(node) || node.className.includes('-leave')) { + return; + } + + this.extraNode = document.createElement('div'); + const { extraNode } = this; + const { getPrefixCls } = this.context; + extraNode.className = `${getPrefixCls('')}-click-animating-node`; + const attributeName = this.getAttributeName(); + node.setAttribute(attributeName, 'true'); + // Not white or transparent or grey + if ( + waveColor && + waveColor !== '#fff' && + waveColor !== '#ffffff' && + waveColor !== 'rgb(255, 255, 255)' && + waveColor !== 'rgba(255, 255, 255, 1)' && + isNotGrey(waveColor) && + !/rgba\((?:\d*, ){3}0\)/.test(waveColor) && // any transparent rgba color + waveColor !== 'transparent' + ) { + extraNode.style.borderColor = waveColor; + + const nodeRoot = node.getRootNode?.() || node.ownerDocument; + const nodeBody = getValidateContainer(nodeRoot) ?? nodeRoot; + + styleForPseudo = updateCSS( + ` + [${getPrefixCls('')}-click-animating-without-extra-node='true']::after, .${getPrefixCls( + '', + )}-click-animating-node { + --antd-wave-shadow-color: ${waveColor}; + }`, + 'antd-wave', + { csp: this.csp, attachTo: nodeBody }, + ); + } + if (insertExtraNode) { + node.appendChild(extraNode); + } + ['transition', 'animation'].forEach(name => { + node.addEventListener(`${name}start`, this.onTransitionStart); + node.addEventListener(`${name}end`, this.onTransitionEnd); + }); + }; + + onTransitionStart = (e: AnimationEvent) => { + if (this.destroyed) { + return; + } + + const node = this.containerRef.current as HTMLDivElement; + if (!e || e.target !== node || this.animationStart) { + return; + } + this.resetEffect(node); + }; + + onTransitionEnd = (e: AnimationEvent) => { + if (!e || e.animationName !== 'fadeEffect') { + return; + } + this.resetEffect(e.target as HTMLElement); + }; + + getAttributeName() { + const { getPrefixCls } = this.context; + const { insertExtraNode } = this.props; + return insertExtraNode + ? `${getPrefixCls('')}-click-animating` + : `${getPrefixCls('')}-click-animating-without-extra-node`; + } + + bindAnimationEvent = (node?: HTMLElement) => { + if ( + !node || + !node.getAttribute || + node.getAttribute('disabled') || + node.className.includes('disabled') + ) { + return; + } + const onClick = (e: MouseEvent) => { + // Fix radio button click twice + if ((e.target as HTMLElement).tagName === 'INPUT' || isHidden(e.target as HTMLElement)) { + return; + } + this.resetEffect(node); + // Get wave color from target + const waveColor = + getComputedStyle(node).getPropertyValue('border-top-color') || // Firefox Compatible + getComputedStyle(node).getPropertyValue('border-color') || + getComputedStyle(node).getPropertyValue('background-color'); + this.clickWaveTimeoutId = window.setTimeout(() => this.onClick(node, waveColor), 0); + + raf.cancel(this.animationStartId); + this.animationStart = true; + + // Render to trigger transition event cost 3 frames. Let's delay 10 frames to reset this. + this.animationStartId = raf(() => { + this.animationStart = false; + }, 10); + }; + node.addEventListener('click', onClick, true); + return { + cancel: () => { + node.removeEventListener('click', onClick, true); + }, + }; + }; + + resetEffect(node: HTMLElement) { + if (!node || node === this.extraNode || !(node instanceof Element)) { + return; + } + const { insertExtraNode } = this.props; + const attributeName = this.getAttributeName(); + node.setAttribute(attributeName, 'false'); // edge has bug on `removeAttribute` #14466 + + if (styleForPseudo) { + styleForPseudo.innerHTML = ''; + } + + if (insertExtraNode && this.extraNode && node.contains(this.extraNode)) { + node.removeChild(this.extraNode); + } + ['transition', 'animation'].forEach(name => { + node.removeEventListener(`${name}start`, this.onTransitionStart); + node.removeEventListener(`${name}end`, this.onTransitionEnd); + }); + } + + renderWave = ({ csp }: ConfigConsumerProps) => { + const { children } = this.props; + this.csp = csp; + + if (!React.isValidElement(children)) return children; + + let ref: React.Ref = this.containerRef; + if (supportRef(children)) { + ref = composeRef((children as any).ref, this.containerRef as any); + } + + return cloneElement(children, { ref }); + }; + + render() { + return {this.renderWave}; + } +} + +export default Wave; diff --git a/components/affix/__tests__/Affix.test.tsx b/components/affix/__tests__/Affix.test.tsx new file mode 100644 index 0000000..c60c124 --- /dev/null +++ b/components/affix/__tests__/Affix.test.tsx @@ -0,0 +1,303 @@ +import React from 'react'; +import type { InternalAffixClass } from '..'; +import Affix from '..'; +import accessibilityTest from '../../../tests/shared/accessibilityTest'; +import rtlTest from '../../../tests/shared/rtlTest'; +import { render, triggerResize, waitFakeTimer } from '../../../tests/utils'; +import Button from '../../button'; +import { addObserveTarget, getObserverEntities } from '../utils'; + +const events: Partial) => void>> = {}; + +class AffixMounter extends React.Component<{ + offsetBottom?: number; + offsetTop?: number; + onTestUpdatePosition?(): void; + onChange?: () => void; + getInstance?: (inst: InternalAffixClass) => void; + style?: React.CSSProperties; +}> { + private container: HTMLDivElement; + + componentDidMount() { + this.container.addEventListener = jest + .fn() + .mockImplementation((event: keyof HTMLElementEventMap, cb: (ev: Partial) => void) => { + events[event] = cb; + }); + } + + getTarget = () => this.container; + + render() { + const { getInstance, ...restProps } = this.props; + return ( +
{ + this.container = node!; + }} + className="container" + > + { + getInstance?.(ele!); + }} + {...restProps} + > + + +
+ ); + } +} + +describe('Affix Render', () => { + rtlTest(Affix); + accessibilityTest(Affix); + + const domMock = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect'); + + const classRect: Record = { + container: { + top: 0, + bottom: 100, + } as DOMRect, + }; + + beforeEach(() => { + jest.useFakeTimers(); + const entities = getObserverEntities(); + entities.splice(0, entities.length); + }); + + beforeAll(() => { + domMock.mockImplementation(function fn(this: HTMLElement) { + return ( + classRect[this.className] || { + top: 0, + bottom: 0, + } + ); + }); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllTimers(); + }); + + afterAll(() => { + domMock.mockRestore(); + }); + + const movePlaceholder = async (top: number) => { + classRect.fixed = { + top, + bottom: top, + } as DOMRect; + if (events.scroll == null) { + throw new Error('scroll should be set'); + } + events.scroll({ + type: 'scroll', + }); + await waitFakeTimer(); + }; + + it('Anchor render perfectly', async () => { + const { container } = render(); + await waitFakeTimer(); + + await movePlaceholder(0); + expect(container.querySelector('.ant-affix')).toBeFalsy(); + + await movePlaceholder(-100); + expect(container.querySelector('.ant-affix')).toBeTruthy(); + + await movePlaceholder(0); + expect(container.querySelector('.ant-affix')).toBeFalsy(); + }); + + it('Anchor correct render when target is null', async () => { + render( null}>test); + await waitFakeTimer(); + }); + + it('support offsetBottom', async () => { + const { container } = render(); + + await waitFakeTimer(); + + await movePlaceholder(300); + expect(container.querySelector('.ant-affix')).toBeTruthy(); + + await movePlaceholder(0); + expect(container.querySelector('.ant-affix')).toBeFalsy(); + + await movePlaceholder(300); + expect(container.querySelector('.ant-affix')).toBeTruthy(); + }); + + it('updatePosition when offsetTop changed', async () => { + const onChange = jest.fn(); + + const { container, rerender } = render(); + await waitFakeTimer(); + + await movePlaceholder(-100); + expect(onChange).toHaveBeenLastCalledWith(true); + expect(container.querySelector('.ant-affix')).toHaveStyle({ top: 0 }); + + rerender(); + await waitFakeTimer(); + expect(container.querySelector('.ant-affix')).toHaveStyle({ top: `10px` }); + }); + + describe('updatePosition when target changed', () => { + it('function change', async () => { + document.body.innerHTML = '
'; + const container = document.getElementById('mounter'); + const getTarget = () => container; + let affixInstance: InternalAffixClass; + const { rerender } = render( + { + affixInstance = node as InternalAffixClass; + }} + target={getTarget} + > + {null} + , + ); + rerender( + { + affixInstance = node as InternalAffixClass; + }} + target={() => null} + > + {null} + , + ); + expect(affixInstance!.state.status).toBe(0); + expect(affixInstance!.state.affixStyle).toBe(undefined); + expect(affixInstance!.state.placeholderStyle).toBe(undefined); + }); + + it('instance change', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + let target: HTMLDivElement | null = container; + + const getTarget = () => target; + const { rerender } = render({null}); + await waitFakeTimer(); + expect(getObserverEntities()).toHaveLength(1); + expect(getObserverEntities()[0].target).toBe(container); + + target = null; + rerender({null}); + expect(getObserverEntities()).toHaveLength(1); + expect(getObserverEntities()[0].target).toBe(window); + }); + + it('check position change before measure', async () => { + const { container } = render( + <> + + + + + + + , + ); + await waitFakeTimer(); + await movePlaceholder(1000); + expect(container.querySelector('.ant-affix')).toBeTruthy(); + }); + + it('do not measure when hidden', async () => { + let affixInstance: InternalAffixClass | null = null; + + const { rerender } = render( + { + affixInstance = inst; + }} + offsetBottom={0} + />, + ); + await waitFakeTimer(); + const firstAffixStyle = affixInstance!.state.affixStyle; + + rerender( + { + affixInstance = inst; + }} + offsetBottom={0} + style={{ display: 'none' }} + />, + ); + await waitFakeTimer(); + const secondAffixStyle = affixInstance!.state.affixStyle; + + expect(firstAffixStyle).toEqual(secondAffixStyle); + }); + }); + + describe('updatePosition when size changed', () => { + it('add class automatically', async () => { + document.body.innerHTML = '
'; + + let affixInstance: InternalAffixClass | null = null; + render( + { + affixInstance = inst; + }} + offsetBottom={0} + />, + { + container: document.getElementById('mounter')!, + }, + ); + + await waitFakeTimer(); + await movePlaceholder(300); + expect(affixInstance!.state.affixStyle).toBeTruthy(); + }); + + // Trigger inner and outer element for the two s. + [ + '.ant-btn', // inner + '.fixed', // outer + ].forEach(selector => { + it(`trigger listener when size change: ${selector}`, async () => { + const updateCalled = jest.fn(); + const { container } = render( + , + { + container: document.getElementById('mounter')!, + }, + ); + + updateCalled.mockReset(); + triggerResize(container.querySelector(selector)!); + + await waitFakeTimer(); + + expect(updateCalled).toHaveBeenCalled(); + }); + }); + + it('addObserveTarget should not Throw Error when target is null', () => { + expect(() => { + addObserveTarget(null); + }).not.toThrow(); + }); + }); +}); diff --git a/components/affix/__tests__/__snapshots__/Affix.test.tsx.snap b/components/affix/__tests__/__snapshots__/Affix.test.tsx.snap new file mode 100644 index 0000000..bd230c7 --- /dev/null +++ b/components/affix/__tests__/__snapshots__/Affix.test.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Affix Render rtl render component should be rendered correctly in RTL direction 1`] = ` +
+
+
+`; diff --git a/components/affix/__tests__/__snapshots__/demo-extend.test.ts.snap b/components/affix/__tests__/__snapshots__/demo-extend.test.ts.snap new file mode 100644 index 0000000..a77adba --- /dev/null +++ b/components/affix/__tests__/__snapshots__/demo-extend.test.ts.snap @@ -0,0 +1,108 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders ./components/affix/demo/basic.md extend context correctly 1`] = ` +Array [ +
+
+ +
+
, +
, +
+
+ +
+
, +] +`; + +exports[`renders ./components/affix/demo/debug.md extend context correctly 1`] = ` +
+
+ Top +
+
+
+
+ +
+
+
+
+ Bottom +
+
+`; + +exports[`renders ./components/affix/demo/on-change.md extend context correctly 1`] = ` +
+
+ +
+
+`; + +exports[`renders ./components/affix/demo/target.md extend context correctly 1`] = ` +
+
+
+
+ +
+
+
+
+`; diff --git a/components/affix/__tests__/__snapshots__/demo.test.ts.snap b/components/affix/__tests__/__snapshots__/demo.test.ts.snap new file mode 100644 index 0000000..fca263f --- /dev/null +++ b/components/affix/__tests__/__snapshots__/demo.test.ts.snap @@ -0,0 +1,108 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders ./components/affix/demo/basic.md correctly 1`] = ` +Array [ +
+
+ +
+
, +
, +
+
+ +
+
, +] +`; + +exports[`renders ./components/affix/demo/debug.md correctly 1`] = ` +
+
+ Top +
+
+
+
+ +
+
+
+
+ Bottom +
+
+`; + +exports[`renders ./components/affix/demo/on-change.md correctly 1`] = ` +
+
+ +
+
+`; + +exports[`renders ./components/affix/demo/target.md correctly 1`] = ` +
+
+
+
+ +
+
+
+
+`; diff --git a/components/affix/__tests__/demo-extend.test.ts b/components/affix/__tests__/demo-extend.test.ts new file mode 100644 index 0000000..80cb860 --- /dev/null +++ b/components/affix/__tests__/demo-extend.test.ts @@ -0,0 +1,3 @@ +import { extendTest } from '../../../tests/shared/demoTest'; + +extendTest('affix'); diff --git a/components/affix/__tests__/demo.test.ts b/components/affix/__tests__/demo.test.ts new file mode 100644 index 0000000..58fcda3 --- /dev/null +++ b/components/affix/__tests__/demo.test.ts @@ -0,0 +1,3 @@ +import demoTest from '../../../tests/shared/demoTest'; + +demoTest('affix'); diff --git a/components/affix/__tests__/image.test.ts b/components/affix/__tests__/image.test.ts new file mode 100644 index 0000000..4ff20f8 --- /dev/null +++ b/components/affix/__tests__/image.test.ts @@ -0,0 +1,5 @@ +import { imageDemoTest } from '../../../tests/shared/imageTest'; + +describe('Affix image', () => { + imageDemoTest('affix'); +}); diff --git a/components/affix/demo/basic.md b/components/affix/demo/basic.md new file mode 100644 index 0000000..f8adf1e --- /dev/null +++ b/components/affix/demo/basic.md @@ -0,0 +1,42 @@ +--- +order: 0 +title: + zh-CN: 基本 + en-US: Basic +--- + +## zh-CN + +最简单的用法。 + +## en-US + +The simplest usage. + +```tsx +import { Affix, Button } from 'antd'; +import React, { useState } from 'react'; + +const App: React.FC = () => { + const [top, setTop] = useState(10); + const [bottom, setBottom] = useState(10); + + return ( + <> + + + +
+ + + + + ); +}; + +export default App; +``` diff --git a/components/affix/demo/debug.md b/components/affix/demo/debug.md new file mode 100644 index 0000000..7540596 --- /dev/null +++ b/components/affix/demo/debug.md @@ -0,0 +1,40 @@ +--- +order: 99 +title: + zh-CN: 调整浏览器大小,观察 Affix 容器是否发生变化。跟随变化为正常。#17678 + en-US: debug +debug: true +--- + +## zh-CN + +DEBUG + +## en-US + +DEBUG + +```tsx +import { Affix, Button } from 'antd'; +import React, { useState } from 'react'; + +const App: React.FC = () => { + const [top, setTop] = useState(10); + + return ( +
+
Top
+ +
+ +
+
+
Bottom
+
+ ); +}; + +export default App; +``` diff --git a/components/affix/demo/on-change.md b/components/affix/demo/on-change.md new file mode 100644 index 0000000..4b74e97 --- /dev/null +++ b/components/affix/demo/on-change.md @@ -0,0 +1,27 @@ +--- +order: 1 +title: + zh-CN: 固定状态改变的回调 + en-US: Callback +--- + +## zh-CN + +可以获得是否固定的状态。 + +## en-US + +Callback with affixed state. + +```tsx +import { Affix, Button } from 'antd'; +import React from 'react'; + +const App: React.FC = () => ( + console.log(affixed)}> + + +); + +export default App; +``` diff --git a/components/affix/demo/target.md b/components/affix/demo/target.md new file mode 100644 index 0000000..4a8fda8 --- /dev/null +++ b/components/affix/demo/target.md @@ -0,0 +1,47 @@ +--- +order: 2 +title: + zh-CN: 滚动容器 + en-US: Container to scroll. +--- + +## zh-CN + +用 `target` 设置 `Affix` 需要监听其滚动事件的元素,默认为 `window`。 + +## en-US + +Set a `target` for 'Affix', which is listen to scroll event of target element (default is `window`). + +```tsx +import { Affix, Button } from 'antd'; +import React, { useState } from 'react'; + +const App: React.FC = () => { + const [container, setContainer] = useState(null); + + return ( +
+
+ container}> + + +
+
+ ); +}; + +export default App; +``` + + diff --git a/components/affix/index.en-US.md b/components/affix/index.en-US.md new file mode 100644 index 0000000..918aadd --- /dev/null +++ b/components/affix/index.en-US.md @@ -0,0 +1,43 @@ +--- +category: Components +type: Navigation +title: Affix +cover: https://gw.alipayobjects.com/zos/alicdn/tX6-md4H6/Affix.svg +--- + +Wrap Affix around another component to make it stick the viewport. + +## When To Use + +On longer web pages, it's helpful to stick component into the viewport. This is common for menus and actions. + +Please note that Affix should not cover other content on the page, especially when the size of the viewport is small. + +## API + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| offsetBottom | Offset from the bottom of the viewport (in pixels) | number | - | +| offsetTop | Offset from the top of the viewport (in pixels) | number | 0 | +| target | Specifies the scrollable area DOM node | () => HTMLElement | () => window | +| onChange | Callback for when Affix state is changed | (affixed?: boolean) => void | - | + +**Note:** Children of `Affix` must not have the property `position: absolute`, but you can set `position: absolute` on `Affix` itself: + +```jsx +... +``` + +## FAQ + +### When binding container with `target` in Affix, elements sometimes move out of the container. + +We only listen to container scroll events for performance consideration. You can add custom listeners if you still want to: + +Related issues:[#3938](https://github.com/ant-design/ant-design/issues/3938) [#5642](https://github.com/ant-design/ant-design/issues/5642) [#16120](https://github.com/ant-design/ant-design/issues/16120) + +### When Affix is ​​used in a horizontal scroll container, the position of the element `left` is incorrect. + +Affix is ​​generally only applicable to areas with one-way scrolling, and only supports usage in vertical scrolling containers. If you want to use it in a horizontal container, you can consider implementing with the native `position: sticky` property. + +Related issues:[#29108](https://github.com/ant-design/ant-design/issues/29108) diff --git a/components/affix/index.tsx b/components/affix/index.tsx new file mode 100644 index 0000000..578bbfd --- /dev/null +++ b/components/affix/index.tsx @@ -0,0 +1,330 @@ +import classNames from 'classnames'; +import ResizeObserver from 'rc-resize-observer'; +import omit from 'rc-util/lib/omit'; +import * as React from 'react'; +import type { ConfigConsumerProps } from '../config-provider'; +import { ConfigContext } from '../config-provider'; +import { throttleByAnimationFrameDecorator } from '../_util/throttleByAnimationFrame'; + +import { + addObserveTarget, + getFixedBottom, + getFixedTop, + getTargetRect, + removeObserveTarget, +} from './utils'; + +function getDefaultTarget() { + return typeof window !== 'undefined' ? window : null; +} + +// Affix +export interface AffixProps { + /** 距离窗口顶部达到指定偏移量后触发 */ + offsetTop?: number; + /** 距离窗口底部达到指定偏移量后触发 */ + offsetBottom?: number; + style?: React.CSSProperties; + /** 固定状态改变时触发的回调函数 */ + onChange?: (affixed?: boolean) => void; + /** 设置 Affix 需要监听其滚动事件的元素,值为一个返回对应 DOM 元素的函数 */ + target?: () => Window | HTMLElement | null; + prefixCls?: string; + className?: string; + children: React.ReactNode; +} + +interface InternalAffixProps extends AffixProps { + affixPrefixCls: string; +} + +enum AffixStatus { + None, + Prepare, +} + +export interface AffixState { + affixStyle?: React.CSSProperties; + placeholderStyle?: React.CSSProperties; + status: AffixStatus; + lastAffix: boolean; + + prevTarget: Window | HTMLElement | null; +} + +class Affix extends React.Component { + static contextType = ConfigContext; + + state: AffixState = { + status: AffixStatus.None, + lastAffix: false, + prevTarget: null, + }; + + placeholderNode: HTMLDivElement; + + fixedNode: HTMLDivElement; + + private timeout: any; + + context: ConfigConsumerProps; + + private getTargetFunc() { + const { getTargetContainer } = this.context; + const { target } = this.props; + + if (target !== undefined) { + return target; + } + + return getTargetContainer ?? getDefaultTarget; + } + + // Event handler + componentDidMount() { + const targetFunc = this.getTargetFunc(); + if (targetFunc) { + // [Legacy] Wait for parent component ref has its value. + // We should use target as directly element instead of function which makes element check hard. + this.timeout = setTimeout(() => { + addObserveTarget(targetFunc(), this); + // Mock Event object. + this.updatePosition(); + }); + } + } + + componentDidUpdate(prevProps: AffixProps) { + const { prevTarget } = this.state; + const targetFunc = this.getTargetFunc(); + const newTarget = targetFunc?.() || null; + + if (prevTarget !== newTarget) { + removeObserveTarget(this); + if (newTarget) { + addObserveTarget(newTarget, this); + // Mock Event object. + this.updatePosition(); + } + + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ prevTarget: newTarget }); + } + + if ( + prevProps.offsetTop !== this.props.offsetTop || + prevProps.offsetBottom !== this.props.offsetBottom + ) { + this.updatePosition(); + } + + this.measure(); + } + + componentWillUnmount() { + clearTimeout(this.timeout); + removeObserveTarget(this); + (this.updatePosition as any).cancel(); + // https://github.com/ant-design/ant-design/issues/22683 + (this.lazyUpdatePosition as any).cancel(); + } + + getOffsetTop = () => { + const { offsetBottom, offsetTop } = this.props; + return offsetBottom === undefined && offsetTop === undefined ? 0 : offsetTop; + }; + + getOffsetBottom = () => this.props.offsetBottom; + + savePlaceholderNode = (node: HTMLDivElement) => { + this.placeholderNode = node; + }; + + saveFixedNode = (node: HTMLDivElement) => { + this.fixedNode = node; + }; + + // =================== Measure =================== + measure = () => { + const { status, lastAffix } = this.state; + const { onChange } = this.props; + const targetFunc = this.getTargetFunc(); + if (status !== AffixStatus.Prepare || !this.fixedNode || !this.placeholderNode || !targetFunc) { + return; + } + + const offsetTop = this.getOffsetTop(); + const offsetBottom = this.getOffsetBottom(); + + const targetNode = targetFunc(); + if (!targetNode) { + return; + } + + const newState: Partial = { + status: AffixStatus.None, + }; + const targetRect = getTargetRect(targetNode); + const placeholderReact = getTargetRect(this.placeholderNode); + const fixedTop = getFixedTop(placeholderReact, targetRect, offsetTop); + const fixedBottom = getFixedBottom(placeholderReact, targetRect, offsetBottom); + + if ( + placeholderReact.top === 0 && + placeholderReact.left === 0 && + placeholderReact.width === 0 && + placeholderReact.height === 0 + ) { + return; + } + + if (fixedTop !== undefined) { + newState.affixStyle = { + position: 'fixed', + top: fixedTop, + width: placeholderReact.width, + height: placeholderReact.height, + }; + newState.placeholderStyle = { + width: placeholderReact.width, + height: placeholderReact.height, + }; + } else if (fixedBottom !== undefined) { + newState.affixStyle = { + position: 'fixed', + bottom: fixedBottom, + width: placeholderReact.width, + height: placeholderReact.height, + }; + newState.placeholderStyle = { + width: placeholderReact.width, + height: placeholderReact.height, + }; + } + + newState.lastAffix = !!newState.affixStyle; + if (onChange && lastAffix !== newState.lastAffix) { + onChange(newState.lastAffix); + } + + this.setState(newState as AffixState); + }; + + // @ts-ignore TS6133 + prepareMeasure = () => { + // event param is used before. Keep compatible ts define here. + this.setState({ + status: AffixStatus.Prepare, + affixStyle: undefined, + placeholderStyle: undefined, + }); + + // Test if `updatePosition` called + if (process.env.NODE_ENV === 'test') { + const { onTestUpdatePosition } = this.props as any; + onTestUpdatePosition?.(); + } + }; + + // Handle realign logic + @throttleByAnimationFrameDecorator() + updatePosition() { + this.prepareMeasure(); + } + + @throttleByAnimationFrameDecorator() + lazyUpdatePosition() { + const targetFunc = this.getTargetFunc(); + const { affixStyle } = this.state; + + // Check position change before measure to make Safari smooth + if (targetFunc && affixStyle) { + const offsetTop = this.getOffsetTop(); + const offsetBottom = this.getOffsetBottom(); + + const targetNode = targetFunc(); + if (targetNode && this.placeholderNode) { + const targetRect = getTargetRect(targetNode); + const placeholderReact = getTargetRect(this.placeholderNode); + const fixedTop = getFixedTop(placeholderReact, targetRect, offsetTop); + const fixedBottom = getFixedBottom(placeholderReact, targetRect, offsetBottom); + + if ( + (fixedTop !== undefined && affixStyle.top === fixedTop) || + (fixedBottom !== undefined && affixStyle.bottom === fixedBottom) + ) { + return; + } + } + } + + // Directly call prepare measure since it's already throttled. + this.prepareMeasure(); + } + + // =================== Render =================== + render() { + const { affixStyle, placeholderStyle } = this.state; + const { affixPrefixCls, children } = this.props; + const className = classNames({ + [affixPrefixCls]: !!affixStyle, + }); + + let props = omit(this.props, [ + 'prefixCls', + 'offsetTop', + 'offsetBottom', + 'target', + 'onChange', + 'affixPrefixCls', + ]); + // Omit this since `onTestUpdatePosition` only works on test. + if (process.env.NODE_ENV === 'test') { + props = omit(props as typeof props & { onTestUpdatePosition: any }, ['onTestUpdatePosition']); + } + + return ( + { + this.updatePosition(); + }} + > +
+ {affixStyle && + + ); + } +} +// just use in test +export type InternalAffixClass = Affix; + +const AffixFC = React.forwardRef((props, ref) => { + const { prefixCls: customizePrefixCls } = props; + const { getPrefixCls } = React.useContext(ConfigContext); + + const affixPrefixCls = getPrefixCls('affix', customizePrefixCls); + + const affixProps: InternalAffixProps = { + ...props, + + affixPrefixCls, + }; + + return ; +}); + +if (process.env.NODE_ENV !== 'production') { + AffixFC.displayName = 'Affix'; +} + +export default AffixFC; diff --git a/components/affix/index.zh-CN.md b/components/affix/index.zh-CN.md new file mode 100644 index 0000000..cffe4ce --- /dev/null +++ b/components/affix/index.zh-CN.md @@ -0,0 +1,44 @@ +--- +category: Components +subtitle: 固钉 +type: 导航 +title: Affix +cover: https://gw.alipayobjects.com/zos/alicdn/tX6-md4H6/Affix.svg +--- + +将页面元素钉在可视范围。 + +## 何时使用 + +当内容区域比较长,需要滚动页面时,这部分内容对应的操作或者导航需要在滚动范围内始终展现。常用于侧边菜单和按钮组合。 + +页面可视范围过小时,慎用此功能以免遮挡页面内容。 + +## API + +| 成员 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| offsetBottom | 距离窗口底部达到指定偏移量后触发 | number | - | +| offsetTop | 距离窗口顶部达到指定偏移量后触发 | number | 0 | +| target | 设置 `Affix` 需要监听其滚动事件的元素,值为一个返回对应 DOM 元素的函数 | () => HTMLElement | () => window | +| onChange | 固定状态改变时触发的回调函数 | (affixed?: boolean) => void | - | + +**注意:**`Affix` 内的元素不要使用绝对定位,如需要绝对定位的效果,可以直接设置 `Affix` 为绝对定位: + +```jsx +... +``` + +## FAQ + +### Affix 使用 `target` 绑定容器时,元素会跑到容器外。 + +从性能角度考虑,我们只监听容器滚动事件。如果希望任意滚动,你可以在窗体添加滚动监听: + +相关 issue:[#3938](https://github.com/ant-design/ant-design/issues/3938) [#5642](https://github.com/ant-design/ant-design/issues/5642) [#16120](https://github.com/ant-design/ant-design/issues/16120) + +### Affix 在水平滚动容器中使用时, 元素 `left` 位置不正确。 + +Affix 一般只适用于单向滚动的区域,只支持在垂直滚动容器中使用。如果希望在水平容器中使用,你可以考虑使用 原生 `position: sticky` 实现。 + +相关 issue: [#29108](https://github.com/ant-design/ant-design/issues/29108) diff --git a/components/affix/style/index.less b/components/affix/style/index.less new file mode 100644 index 0000000..3762903 --- /dev/null +++ b/components/affix/style/index.less @@ -0,0 +1,6 @@ +@import '../../style/themes/index'; + +.@{ant-prefix}-affix { + position: fixed; + z-index: @zindex-affix; +} diff --git a/components/affix/style/index.tsx b/components/affix/style/index.tsx new file mode 100644 index 0000000..3a3ab0d --- /dev/null +++ b/components/affix/style/index.tsx @@ -0,0 +1,2 @@ +import '../../style/index.less'; +import './index.less'; diff --git a/components/affix/utils.ts b/components/affix/utils.ts new file mode 100644 index 0000000..e72b93b --- /dev/null +++ b/components/affix/utils.ts @@ -0,0 +1,102 @@ +import addEventListener from 'rc-util/lib/Dom/addEventListener'; + +export type BindElement = HTMLElement | Window | null | undefined; + +export function getTargetRect(target: BindElement): DOMRect { + return target !== window + ? (target as HTMLElement).getBoundingClientRect() + : ({ top: 0, bottom: window.innerHeight } as DOMRect); +} + +export function getFixedTop(placeholderReact: DOMRect, targetRect: DOMRect, offsetTop?: number) { + if (offsetTop !== undefined && targetRect.top > placeholderReact.top - offsetTop) { + return offsetTop + targetRect.top; + } + return undefined; +} + +export function getFixedBottom( + placeholderReact: DOMRect, + targetRect: DOMRect, + offsetBottom?: number, +) { + if (offsetBottom !== undefined && targetRect.bottom < placeholderReact.bottom + offsetBottom) { + const targetBottomOffset = window.innerHeight - targetRect.bottom; + return offsetBottom + targetBottomOffset; + } + return undefined; +} + +// ======================== Observer ======================== +const TRIGGER_EVENTS = [ + 'resize', + 'scroll', + 'touchstart', + 'touchmove', + 'touchend', + 'pageshow', + 'load', +]; + +interface ObserverEntity { + target: HTMLElement | Window; + affixList: any[]; + eventHandlers: { [eventName: string]: any }; +} + +let observerEntities: ObserverEntity[] = []; + +export function getObserverEntities() { + // Only used in test env. Can be removed if refactor. + return observerEntities; +} + +export function addObserveTarget(target: HTMLElement | Window | null, affix?: T): void { + if (!target) { + return; + } + + let entity: ObserverEntity | undefined = observerEntities.find(item => item.target === target); + + if (entity) { + entity.affixList.push(affix); + } else { + entity = { + target, + affixList: [affix], + eventHandlers: {}, + }; + observerEntities.push(entity); + + // Add listener + TRIGGER_EVENTS.forEach(eventName => { + entity!.eventHandlers[eventName] = addEventListener(target, eventName, () => { + entity!.affixList.forEach(targetAffix => { + targetAffix.lazyUpdatePosition(); + }); + }); + }); + } +} + +export function removeObserveTarget(affix: T): void { + const observerEntity = observerEntities.find(oriObserverEntity => { + const hasAffix = oriObserverEntity.affixList.some(item => item === affix); + if (hasAffix) { + oriObserverEntity.affixList = oriObserverEntity.affixList.filter(item => item !== affix); + } + return hasAffix; + }); + + if (observerEntity && observerEntity.affixList.length === 0) { + observerEntities = observerEntities.filter(item => item !== observerEntity); + + // Remove listener + TRIGGER_EVENTS.forEach(eventName => { + const handler = observerEntity.eventHandlers[eventName]; + if (handler && handler.remove) { + handler.remove(); + } + }); + } +} diff --git a/components/alert/ErrorBoundary.tsx b/components/alert/ErrorBoundary.tsx new file mode 100644 index 0000000..a5e2e7e --- /dev/null +++ b/components/alert/ErrorBoundary.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import Alert from '.'; + +interface ErrorBoundaryProps { + message?: React.ReactNode; + description?: React.ReactNode; + children?: React.ReactNode; +} + +interface ErrorBoundaryStates { + error?: Error | null; + info?: { + componentStack?: string; + }; +} + +class ErrorBoundary extends React.Component { + state = { + error: undefined, + info: { + componentStack: '', + }, + }; + + componentDidCatch(error: Error | null, info: object) { + this.setState({ error, info }); + } + + render() { + const { message, description, children } = this.props; + const { error, info } = this.state; + const componentStack = info && info.componentStack ? info.componentStack : null; + const errorMessage = typeof message === 'undefined' ? (error || '').toString() : message; + const errorDescription = typeof description === 'undefined' ? componentStack : description; + if (error) { + return ( + {errorDescription}} /> + ); + } + return children; + } +} + +export default ErrorBoundary; diff --git a/components/alert/__tests__/__snapshots__/demo-extend.test.ts.snap b/components/alert/__tests__/__snapshots__/demo-extend.test.ts.snap new file mode 100644 index 0000000..fa01339 --- /dev/null +++ b/components/alert/__tests__/__snapshots__/demo-extend.test.ts.snap @@ -0,0 +1,1496 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders ./components/alert/demo/action.md extend context correctly 1`] = ` +Array [ + , + , + , + , +] +`; + +exports[`renders ./components/alert/demo/banner.md extend context correctly 1`] = ` +Array [ + , +
, + , +
, + , +
, + , +] +`; + +exports[`renders ./components/alert/demo/basic.md extend context correctly 1`] = ` + +`; + +exports[`renders ./components/alert/demo/closable.md extend context correctly 1`] = ` +Array [ + , + , +] +`; + +exports[`renders ./components/alert/demo/close-text.md extend context correctly 1`] = ` + +`; + +exports[`renders ./components/alert/demo/custom-icon.md extend context correctly 1`] = ` +Array [ + , + , + , + , + , + , + , + , + , +] +`; + +exports[`renders ./components/alert/demo/description.md extend context correctly 1`] = ` +Array [ + , + , + , + , +] +`; + +exports[`renders ./components/alert/demo/error-boundary.md extend context correctly 1`] = ` + +`; + +exports[`renders ./components/alert/demo/icon.md extend context correctly 1`] = ` +Array [ + , + , + , + , + , + , + , + , +] +`; + +exports[`renders ./components/alert/demo/loop-banner.md extend context correctly 1`] = ` + +`; + +exports[`renders ./components/alert/demo/smooth-closed.md extend context correctly 1`] = ` +
+ +

+ placeholder text here +

+
+`; + +exports[`renders ./components/alert/demo/style.md extend context correctly 1`] = ` +Array [ + , + , + , + , +] +`; diff --git a/components/alert/__tests__/__snapshots__/demo.test.ts.snap b/components/alert/__tests__/__snapshots__/demo.test.ts.snap new file mode 100644 index 0000000..27fee1e --- /dev/null +++ b/components/alert/__tests__/__snapshots__/demo.test.ts.snap @@ -0,0 +1,1496 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders ./components/alert/demo/action.md correctly 1`] = ` +Array [ + , + , + , + , +] +`; + +exports[`renders ./components/alert/demo/banner.md correctly 1`] = ` +Array [ + , +
, + , +
, + , +
, + , +] +`; + +exports[`renders ./components/alert/demo/basic.md correctly 1`] = ` + +`; + +exports[`renders ./components/alert/demo/closable.md correctly 1`] = ` +Array [ + , + , +] +`; + +exports[`renders ./components/alert/demo/close-text.md correctly 1`] = ` + +`; + +exports[`renders ./components/alert/demo/custom-icon.md correctly 1`] = ` +Array [ + , + , + , + , + , + , + , + , + , +] +`; + +exports[`renders ./components/alert/demo/description.md correctly 1`] = ` +Array [ + , + , + , + , +] +`; + +exports[`renders ./components/alert/demo/error-boundary.md correctly 1`] = ` + +`; + +exports[`renders ./components/alert/demo/icon.md correctly 1`] = ` +Array [ + , + , + , + , + , + , + , + , +] +`; + +exports[`renders ./components/alert/demo/loop-banner.md correctly 1`] = ` + +`; + +exports[`renders ./components/alert/demo/smooth-closed.md correctly 1`] = ` +
+ +

+ placeholder text here +

+
+`; + +exports[`renders ./components/alert/demo/style.md correctly 1`] = ` +Array [ + , + , + , + , +] +`; diff --git a/components/alert/__tests__/__snapshots__/index.test.tsx.snap b/components/alert/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000..374770f --- /dev/null +++ b/components/alert/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Alert custom action 1`] = ` + +`; + +exports[`Alert rtl render component should be rendered correctly in RTL direction 1`] = ` +