diff --git a/internal/playground/src/router.tsx b/internal/playground/src/router.tsx index e017074a..42ca7867 100644 --- a/internal/playground/src/router.tsx +++ b/internal/playground/src/router.tsx @@ -161,6 +161,11 @@ export const baseRouter = [ name: 'calendar 日历', path: '/calendar', }, + { + element: getDemos(import.meta.glob('~/input-popover/demo/*.tsx')), + name: 'input-popover 输入弹窗', + path: '/input-popover', + }, /*insert target*/ ]; diff --git a/packages/components/src/index.scss b/packages/components/src/index.scss index 16a112af..efaf9396 100644 --- a/packages/components/src/index.scss +++ b/packages/components/src/index.scss @@ -29,3 +29,4 @@ @import './empty'; @import './timeline'; @import './calendar'; +@import './input-popover'; diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 33de564b..f11db263 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -35,3 +35,4 @@ export * from './slider'; export * from './empty'; export * from './timeline'; export * from './calendar'; +export * from './input-popover'; diff --git a/packages/components/src/input-popover/InputPopover.tsx b/packages/components/src/input-popover/InputPopover.tsx new file mode 100644 index 00000000..bbb248c9 --- /dev/null +++ b/packages/components/src/input-popover/InputPopover.tsx @@ -0,0 +1,160 @@ +import { + useEventListenerOnMounted, + useStateWithTrailClear, + useForwardRef, + getClasses, + useWatch, +} from '@pkg/shared'; +import type { InputPopoverProps } from './input-popover.types'; +import React, { useEffect, useState, useRef } from 'react'; +import type { RequiredPart } from '@tool-pack/types'; +import { filter as rxFilter, fromEvent } from 'rxjs'; +import { transitionCBAdapter } from '~/transition'; +import { getClassNames } from '@tool-pack/basic'; +import { InputSkin } from '~/input/components'; +import { useEsc } from '~/dialog/dialog.hooks'; +import { TabTrigger } from './components'; +import { Popover } from '~/popover'; + +const defaultProps = {} satisfies Partial; +const cls = getClasses('input-popover', [], []); + +export const InputPopover: React.FC = React.forwardRef< + HTMLLabelElement, + InputPopoverProps +>((props, ref) => { + const { + popoverProps = {}, + onVisibleChange, + tabTriggerRef, + attrs = {}, + children, + disabled, + visible, + onFocus, + onBlur, + active, + status, + size, + } = props as RequiredPart; + + // 当点击触发元素或窗体时,禁止触发 blur + const skipBlurRef = useRef(false); + const _tabTriggerRef = useForwardRef(tabTriggerRef); + const [opened, setOpened] = useState(false); + const [focused, setFocused] = useState(); + const [show, setShow] = useStateWithTrailClear(visible); + + useWatch(visible, (v) => !v && (skipBlurRef.current = true)); + + // 页面 blur + useEventListenerOnMounted( + window, + 'blur', + close, + undefined, + // active, + false, + ); + + useEffect(() => { + if (!opened) return; + + // 监听 tab 键 + const sub$ = fromEvent(window, 'keydown') + .pipe(rxFilter((e) => e.code === 'Tab')) + .subscribe(close); + + return () => sub$.unsubscribe(); + }, [opened]); + + useEffect(() => { + if (focused === undefined) return; + if (focused) onFocus?.(); + else onBlur?.(); + }, [focused]); + + useEsc(opened, true, closeWithFocus); + + const popoverOn = transitionCBAdapter({ + onAfterLeave() { + if (skipBlurRef.current) { + _tabTriggerRef.current?.focus(); + skipBlurRef.current = false; + } else { + setFocused(false); + } + }, + onBeforeEnter: () => { + setOpened(true); + onVisibleChange?.(true); + setFocused(true); + }, + onBeforeLeave: () => { + setOpened(false); + onVisibleChange?.(false); + }, + }); + + return ( + (popoverOn(...args), popoverProps.on?.(...args))} + widthByTrigger={popoverProps.widthByTrigger ?? true} + placement={popoverProps.placement || 'bottom'} + trigger={popoverProps.trigger ?? 'click'} + visible={show} + > + + + {children} + + + ); + + function _onFocus(): void { + setFocused(true); + } + function _onBlur(): void { + if (opened || skipBlurRef.current) return; + setFocused(false); + } + function close(): void { + setOpened(false); + setShow(false); + } + function open(): void { + setOpened(true); + setShow(true); + } + function closeWithFocus(): void { + skipBlurRef.current = true; + close(); + } + function handleClickRoot(e: React.MouseEvent): void { + attrs.onClick?.(e); + if (disabled || !opened) return; + skipBlurRef.current = true; + } +}); + +InputPopover.defaultProps = defaultProps; +InputPopover.displayName = 'InputPopover'; diff --git a/packages/components/src/input-popover/__tests__/InputPopover.keyboard.test.tsx b/packages/components/src/input-popover/__tests__/InputPopover.keyboard.test.tsx new file mode 100644 index 00000000..9a4dfbaf --- /dev/null +++ b/packages/components/src/input-popover/__tests__/InputPopover.keyboard.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, act } from '@testing-library/react'; +import { InputPopover } from '..'; + +describe('InputPopover.keyboard', () => { + const cls = { + invisible: 't-transition--invisible', + active: 't-input-skin--active', + }; + + it('should close popover when esc keydown', () => { + jest.useFakeTimers(); + const { container } = render(); + expect(container.firstChild).toHaveClass(cls.active); + expect(getBalloon()).not.toHaveClass(cls.invisible); + + fireEvent.keyDown(window, { key: 'Escape' }); + act(() => jest.advanceTimersByTime(500)); + + expect(container.firstChild).toHaveClass(cls.active); + expect(getBalloon()).toHaveClass(cls.invisible); + }); + + it('should open popover when tab and enter keydown', () => { + jest.useFakeTimers(); + const { container } = render(); + + expect(container.firstChild).not.toHaveClass(cls.active); + expect(getBalloon()).toBeNull(); + + // 在测试环境按下 tab 键不会让 input 获得焦点 + // fireEvent.keyDown(window, { key: 'Tab' }); + // fireEvent.keyUp(window, { key: 'Tab' }); + fireEvent.focus(getTrigger()); + expect(container.firstChild).toHaveClass(cls.active); + + fireEvent.keyDown(getTrigger(), { code: 'Enter' }); + act(() => jest.advanceTimersByTime(500)); + act(() => jest.advanceTimersByTime(500)); + expect(getBalloon()).not.toBeNull(); + }); +}); +function getTrigger(): HTMLInputElement { + return $('.t-input-popover-tab-trigger')!; +} +function $(selectors: string): null | T { + return document.querySelector(selectors); +} +function getBalloon() { + return $('.t-word-balloon') as HTMLDivElement; +} diff --git a/packages/components/src/input-popover/__tests__/InputPopover.test.tsx b/packages/components/src/input-popover/__tests__/InputPopover.test.tsx new file mode 100644 index 00000000..1c994ea7 --- /dev/null +++ b/packages/components/src/input-popover/__tests__/InputPopover.test.tsx @@ -0,0 +1,15 @@ +import { render, act } from '@testing-library/react'; +import { testAttrs } from '~/testAttrs'; +import { InputPopover } from '..'; + +describe('InputPopover', () => { + testAttrs(InputPopover); + + it('basic', () => { + jest.useFakeTimers(); + render(); + act(() => jest.advanceTimersByTime(500)); + act(() => jest.advanceTimersByTime(500)); + expect(document.body).toMatchSnapshot(); + }); +}); diff --git a/packages/components/src/input-popover/__tests__/__snapshots__/InputPopover.test.tsx.snap b/packages/components/src/input-popover/__tests__/__snapshots__/InputPopover.test.tsx.snap new file mode 100644 index 00000000..2f460839 --- /dev/null +++ b/packages/components/src/input-popover/__tests__/__snapshots__/InputPopover.test.tsx.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InputPopover basic 1`] = ` + +
+ +
+
+
+
+ + + +
+
+ +`; diff --git a/packages/components/src/select/components/TabTrigger.tsx b/packages/components/src/input-popover/components/TabTrigger.tsx similarity index 92% rename from packages/components/src/select/components/TabTrigger.tsx rename to packages/components/src/input-popover/components/TabTrigger.tsx index 73981b4c..4ba68ef8 100644 --- a/packages/components/src/select/components/TabTrigger.tsx +++ b/packages/components/src/input-popover/components/TabTrigger.tsx @@ -10,7 +10,7 @@ interface Props { opened: boolean; } -const cls = getComponentClass('select-tab-trigger'); +const cls = getComponentClass('input-popover-tab-trigger'); export const TabTrigger: React.FC = React.forwardRef< HTMLInputElement, diff --git a/packages/components/src/input-popover/components/index.ts b/packages/components/src/input-popover/components/index.ts new file mode 100644 index 00000000..e6a21993 --- /dev/null +++ b/packages/components/src/input-popover/components/index.ts @@ -0,0 +1 @@ +export * from './TabTrigger'; diff --git a/packages/components/src/input-popover/demo/basic.tsx b/packages/components/src/input-popover/demo/basic.tsx new file mode 100644 index 00000000..00378e50 --- /dev/null +++ b/packages/components/src/input-popover/demo/basic.tsx @@ -0,0 +1,17 @@ +/** + * title: 基础用法 + * description: InputPopover 基础用法。 + */ + +import { InputPopover } from '@tool-pack/react-ui'; +import React from 'react'; + +const App: React.FC = () => { + return ( + + + + ); +}; + +export default App; diff --git a/packages/components/src/input-popover/index.scss b/packages/components/src/input-popover/index.scss new file mode 100644 index 00000000..b5dd368c --- /dev/null +++ b/packages/components/src/input-popover/index.scss @@ -0,0 +1,18 @@ +@use '../namespace' as Name; + +$r: Name.$input-popover; +$skin: Name.$input-skin; +$tt: #{$r}-tab-trigger; + +.#{$r} { + &.#{$skin}:not(.#{$skin}--disabled) { + cursor: pointer; + } +} +.#{$tt} { + padding: 0; + width: 0; + border: 0; + opacity: 0; + outline: 0; +} diff --git a/packages/components/src/input-popover/index.ts b/packages/components/src/input-popover/index.ts new file mode 100644 index 00000000..47fcd9a4 --- /dev/null +++ b/packages/components/src/input-popover/index.ts @@ -0,0 +1,2 @@ +export type { InputPopoverProps } from './input-popover.types'; +export * from './InputPopover'; diff --git a/packages/components/src/input-popover/index.zh-CN.md b/packages/components/src/input-popover/index.zh-CN.md new file mode 100644 index 00000000..c071dbcd --- /dev/null +++ b/packages/components/src/input-popover/index.zh-CN.md @@ -0,0 +1,28 @@ +--- +category: Components +title: InputPopover 输入弹窗 +atomId: InputPopover +debug: true +demo: + cols: 2 +group: + title: 内部 +--- + +InputPopover 输入弹窗。 + +## 代码演示 + + + + +## API + +InputPopover 的属性说明如下: + +| 属性 | 说明 | 类型 | 默认值 | 版本 | +| ----- | ------------- | ----------------------------------------------- | ------ | ---- | +| -- | -- | -- | -- | -- | +| attrs | html 标签属性 | Partial\> | -- | -- | + +其他说明。 diff --git a/packages/components/src/input-popover/input-popover.types.ts b/packages/components/src/input-popover/input-popover.types.ts new file mode 100644 index 00000000..8627510b --- /dev/null +++ b/packages/components/src/input-popover/input-popover.types.ts @@ -0,0 +1,12 @@ +import type { InputSkinProps } from '~/input/components'; +import type { PopoverProps } from '~/popover'; +import React from 'react'; + +export interface InputPopoverProps + extends InputSkinProps, + Pick { + tabTriggerRef?: React.Ref; + popoverProps?: Partial; + onFocus?: () => void; + onBlur?: () => void; +} diff --git a/packages/components/src/input/Input.tsx b/packages/components/src/input/Input.tsx index 25e80c87..a9a06498 100644 --- a/packages/components/src/input/Input.tsx +++ b/packages/components/src/input/Input.tsx @@ -1,20 +1,19 @@ import { - getSizeClassName, useForceUpdate, useForwardRef, getClasses, useWatch, } from '@pkg/shared'; +import { InputSwitch, InputSuffix, InputSkin } from './components'; import type { RequiredPart } from '@tool-pack/types'; -import { InnerInput, Suffix } from './components'; import { getClassNames } from '@tool-pack/basic'; import type { InputProps } from './input.types'; import React, { useState, useRef } from 'react'; const cls = getClasses( 'input', - ['clear', 'prefix', 'suffix', 'loading', 'icon', 'count', 'switch'], - ['focus', 'clearable', 'disabled', 'loading', 'textarea', 'autosize'], + ['prefix', 'count'], + ['clearable', 'loading', 'textarea', 'autosize'], ); const defaultProps = { showPasswordOn: 'click', @@ -70,27 +69,25 @@ export const Input: React.FC = React.forwardRef< ); return ( - + ); function getCountView() { diff --git a/packages/components/src/input/__tests__/__snapshots__/Input.test.tsx.snap b/packages/components/src/input/__tests__/__snapshots__/Input.test.tsx.snap index 3cec0e6b..36326043 100644 --- a/packages/components/src/input/__tests__/__snapshots__/Input.test.tsx.snap +++ b/packages/components/src/input/__tests__/__snapshots__/Input.test.tsx.snap @@ -3,7 +3,7 @@ exports[`Input basic 1`] = `