Skip to content

Commit

Permalink
Merge pull request #88 from js-tool-pack/input-popover
Browse files Browse the repository at this point in the history
Input popover
  • Loading branch information
mengxinssfd authored Dec 26, 2023
2 parents 5fd475c + 47934c2 commit ba549dd
Show file tree
Hide file tree
Showing 27 changed files with 629 additions and 249 deletions.
5 changes: 5 additions & 0 deletions internal/playground/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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*/
];

Expand Down
1 change: 1 addition & 0 deletions packages/components/src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@
@import './empty';
@import './timeline';
@import './calendar';
@import './input-popover';
1 change: 1 addition & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ export * from './slider';
export * from './empty';
export * from './timeline';
export * from './calendar';
export * from './input-popover';
160 changes: 160 additions & 0 deletions packages/components/src/input-popover/InputPopover.tsx
Original file line number Diff line number Diff line change
@@ -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<InputPopoverProps>;
const cls = getClasses('input-popover', [], []);

export const InputPopover: React.FC<InputPopoverProps> = React.forwardRef<
HTMLLabelElement,
InputPopoverProps
>((props, ref) => {
const {
popoverProps = {},
onVisibleChange,
tabTriggerRef,
attrs = {},
children,
disabled,
visible,
onFocus,
onBlur,
active,
status,
size,
} = props as RequiredPart<InputPopoverProps, keyof typeof defaultProps>;

// 当点击触发元素或窗体时,禁止触发 blur
const skipBlurRef = useRef(false);
const _tabTriggerRef = useForwardRef(tabTriggerRef);
const [opened, setOpened] = useState(false);
const [focused, setFocused] = useState<boolean>();
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<KeyboardEvent>(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 (
<Popover
{...popoverProps}
on={(...args) => (popoverOn(...args), popoverProps.on?.(...args))}
widthByTrigger={popoverProps.widthByTrigger ?? true}
placement={popoverProps.placement || 'bottom'}
trigger={popoverProps.trigger ?? 'click'}
visible={show}
>
<InputSkin
attrs={{
...attrs,
className: getClassNames(attrs.className, cls.root),
onClick: handleClickRoot,
}}
active={!disabled && (active || opened || focused)}
disabled={disabled}
status={status}
size={size}
ref={ref}
>
<TabTrigger
ref={_tabTriggerRef}
disabled={disabled}
onFocus={_onFocus}
onBlur={_onBlur}
opened={opened}
onOpen={open}
/>
{children}
</InputSkin>
</Popover>
);

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<HTMLLabelElement>): void {
attrs.onClick?.(e);
if (disabled || !opened) return;
skipBlurRef.current = true;
}
});

InputPopover.defaultProps = defaultProps;
InputPopover.displayName = 'InputPopover';
Original file line number Diff line number Diff line change
@@ -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(<InputPopover visible />);
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(<InputPopover />);

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 $<T extends HTMLElement = HTMLElement>(selectors: string): null | T {
return document.querySelector<T>(selectors);
}
function getBalloon() {
return $('.t-word-balloon') as HTMLDivElement;
}
Original file line number Diff line number Diff line change
@@ -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(<InputPopover visible />);
act(() => jest.advanceTimersByTime(500));
act(() => jest.advanceTimersByTime(500));
expect(document.body).toMatchSnapshot();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`InputPopover basic 1`] = `
<body>
<div>
<label
class="t-input-skin t-input-popover t--size-m t-input-skin--active"
>
<input
class="t-input-popover-tab-trigger"
tabindex="-1"
/>
</label>
</div>
<div
class="t-word-balloon t-popover t-word-balloon--bottom"
style="width: 0px; top: 10px; left: 0px;"
>
<div
class="t-word-balloon__content"
/>
<div
class="t-word-balloon__arrow"
>
<svg
viewBox="-8 -5.5 16 5.515"
>
<path
d="M8-5.5A4 4 180 005.1716-4.3284L1.4142-.5711A2 2 180 01-1.4142-.5711L-5.1716-4.3284A4 4 180 00-8-5.5Z"
/>
</svg>
</div>
</div>
</body>
`;
Original file line number Diff line number Diff line change
Expand Up @@ -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<Props> = React.forwardRef<
HTMLInputElement,
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/input-popover/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './TabTrigger';
17 changes: 17 additions & 0 deletions packages/components/src/input-popover/demo/basic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* title: 基础用法
* description: InputPopover 基础用法。
*/

import { InputPopover } from '@tool-pack/react-ui';
import React from 'react';

const App: React.FC = () => {
return (
<InputPopover>
<input />
</InputPopover>
);
};

export default App;
18 changes: 18 additions & 0 deletions packages/components/src/input-popover/index.scss
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions packages/components/src/input-popover/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { InputPopoverProps } from './input-popover.types';
export * from './InputPopover';
28 changes: 28 additions & 0 deletions packages/components/src/input-popover/index.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
category: Components
title: InputPopover 输入弹窗
atomId: InputPopover
debug: true
demo:
cols: 2
group:
title: 内部
---

InputPopover 输入弹窗。

## 代码演示

<!-- prettier-ignore -->
<code src="./demo/basic.tsx"></code>

## API

InputPopover 的属性说明如下:

| 属性 | 说明 | 类型 | 默认值 | 版本 |
| ----- | ------------- | ----------------------------------------------- | ------ | ---- |
| -- | -- | -- | -- | -- |
| attrs | html 标签属性 | Partial\<React.HTMLAttributes\<HTMLDivElement>> | -- | -- |

其他说明。
12 changes: 12 additions & 0 deletions packages/components/src/input-popover/input-popover.types.ts
Original file line number Diff line number Diff line change
@@ -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<PopoverProps, 'onVisibleChange' | 'visible'> {
tabTriggerRef?: React.Ref<HTMLInputElement>;
popoverProps?: Partial<PopoverProps>;
onFocus?: () => void;
onBlur?: () => void;
}
Loading

0 comments on commit ba549dd

Please sign in to comment.