@rainbow-oh/yee-c 0.15.1 → 0.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainbow-oh/yee-c",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "yee basic components",
5
5
  "keywords": [
6
6
  "react",
@@ -90,9 +90,10 @@ const Dialog = (baseprops: DialogProps) => {
90
90
  // lock focus in the dialog
91
91
  useLockFocus(contentElement, delayOpen);
92
92
 
93
- // enable esc to close dialog
93
+ // enable esc to close dialog; gated on open so a mounted-but-closed
94
+ // dialog does not occupy the esc layer stack and swallow the top layer
94
95
  useEsc({
95
- enabled: keyboard,
96
+ enabled: keyboard && open,
96
97
  onEsc: () => {
97
98
  onCancel?.();
98
99
  },
@@ -87,8 +87,10 @@ const Drawer = (baseprops: DrawerProps) => {
87
87
 
88
88
  useLockFocus(contentRef.current, delayOpen);
89
89
 
90
+ // gated on open so a mounted-but-closed drawer does not occupy the esc
91
+ // layer stack and swallow the top layer
90
92
  useEsc({
91
- enabled: keyboard,
93
+ enabled: keyboard && open,
92
94
  onEsc: () => {
93
95
  onClose?.();
94
96
  },
@@ -48,8 +48,10 @@ const Popup: React.ForwardRefRenderFunction<
48
48
  const internalRef = useRef<HTMLDivElement>(null);
49
49
  const componentRef = (ref as React.RefObject<HTMLDivElement>) || internalRef;
50
50
 
51
+ // gated on open so a mounted-but-closed viewer does not occupy the esc
52
+ // layer stack and swallow the top layer
51
53
  useEsc({
52
- enabled: keyboard,
54
+ enabled: keyboard && open,
53
55
  onEsc: () => {
54
56
  onClose?.();
55
57
  },
@@ -104,7 +104,7 @@ Filter configuration of `ColumnProps.filter`.
104
104
  | searchable | `boolean` | Whether searchable; renders a search input when `items` is unset | `true` |
105
105
  | filterOnClose | `boolean` | Whether to apply the filter when the panel closes | `true` |
106
106
  | icon | `(filtered: boolean) => React.ReactNode` | Custom filter icon | - |
107
- | render | `() => React.ReactNode` | Custom filter panel content | - |
107
+ | render | `(ctx: { close: () => void }) => React.ReactNode` | Custom filter panel content; `ctx.close()` closes the filter popover | - |
108
108
  | onFilter | `(value: FilterValue, record: object) => boolean` | Local filter function; falls back to default string-includes matching when unset | - |
109
109
  | filtered | `boolean` | Force the filter icon highlight (takes precedence over `filteredValue`) | - |
110
110
  | filteredValue | `FilterValue \| FilterValue[]` | (Controlled) Filter value. When set, the column's filter state is fully controlled: panel echo, icon highlight and the `filters` in `onChange` all derive from it; reset with `''` / `[]` | - |
@@ -109,7 +109,7 @@ toc: 'content'
109
109
  | searchable | `boolean` | 是否可搜索;未配置 `items` 时渲染搜索输入框 | `true` |
110
110
  | filterOnClose | `boolean` | 关闭筛选面板时是否自动应用筛选 | `true` |
111
111
  | icon | `(filtered: boolean) => React.ReactNode` | 自定义筛选图标 | - |
112
- | render | `() => React.ReactNode` | 自定义筛选面板内容 | - |
112
+ | render | `(ctx: { close: () => void }) => React.ReactNode` | 自定义筛选面板内容;`ctx.close()` 关闭筛选弹层 | - |
113
113
  | onFilter | `(value: FilterValue, record: object) => boolean` | 本地过滤函数;不配置时使用默认的字符串包含匹配 | - |
114
114
  | filtered | `boolean` | 强制筛选图标高亮(优先级高于由 `filteredValue` 派生的高亮) | - |
115
115
  | filteredValue | `FilterValue \| FilterValue[]` | (受控)筛选值。配置后该列筛选状态完全受控:面板回显、图标高亮、`onChange` 的 `filters` 均以其为准;重置传 `''` / `[]` | - |
@@ -234,7 +234,7 @@ export interface ColumnProps {
234
234
  /**
235
235
  * Custom filter function, e.g. custom search
236
236
  */
237
- render?: () => React.ReactNode;
237
+ render?: (ctx: { close: () => void }) => React.ReactNode;
238
238
  /**
239
239
  * Filter callback function
240
240
  */
@@ -149,7 +149,7 @@ const HeaderFilter = React.memo((props: any) => {
149
149
  ) : null;
150
150
 
151
151
  if (render) {
152
- popup = render();
152
+ popup = render({ close: () => setOpen(false) });
153
153
  } else if (items) {
154
154
  let dataSource = [];
155
155
  const inputValue = getStrLower(searchValue);
@@ -0,0 +1,90 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render } from '@testing-library/react';
3
+ import React, { useState } from 'react';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import Dialog from '../Dialog';
6
+ import Popover from '../Popover';
7
+
8
+ afterEach(() => {
9
+ cleanup();
10
+ });
11
+
12
+ // jsdom has no ResizeObserver; @rc-component/trigger uses it for popup align.
13
+ if (typeof globalThis.ResizeObserver === 'undefined') {
14
+ globalThis.ResizeObserver = class {
15
+ observe() {}
16
+ unobserve() {}
17
+ disconnect() {}
18
+ } as unknown as typeof ResizeObserver;
19
+ }
20
+
21
+ // useEsc 层级栈:同一 document keydown 会到达所有已启用监听,
22
+ // Esc 必须只关最上层(后启用者),再按才逐层关闭。
23
+ describe('useEsc layering', () => {
24
+ it('closes only the topmost layer (popover) on Escape, dialog on the next', () => {
25
+ const onPopoverChange = vi.fn();
26
+ const onDialogCancel = vi.fn();
27
+
28
+ function LayeredApp() {
29
+ const [popoverOpen, setPopoverOpen] = useState(true);
30
+ return (
31
+ <Dialog open onCancel={onDialogCancel} keyboard title="dialog">
32
+ <Popover
33
+ trigger="click"
34
+ open={popoverOpen}
35
+ onOpenChange={(open: boolean) => {
36
+ setPopoverOpen(open);
37
+ onPopoverChange(open);
38
+ }}
39
+ content={<div>filter panel</div>}
40
+ >
41
+ <button type="button">trigger</button>
42
+ </Popover>
43
+ </Dialog>
44
+ );
45
+ }
46
+
47
+ render(<LayeredApp />);
48
+
49
+ // Dialog 先开、Popover 后开 → Popover 是最上层
50
+ fireEvent.keyDown(document, { key: 'Escape' });
51
+ expect(onPopoverChange).toHaveBeenCalledWith(false);
52
+ expect(onDialogCancel).not.toHaveBeenCalled();
53
+
54
+ // Popover 已关,Esc 落到 Dialog 层
55
+ fireEvent.keyDown(document, { key: 'Escape' });
56
+ expect(onDialogCancel).toHaveBeenCalledTimes(1);
57
+ });
58
+
59
+ it('closes the dialog when it is the only enabled layer', () => {
60
+ const onDialogCancel = vi.fn();
61
+ render(
62
+ <Dialog open onCancel={onDialogCancel} keyboard title="dialog">
63
+ <div>body</div>
64
+ </Dialog>,
65
+ );
66
+
67
+ fireEvent.keyDown(document, { key: 'Escape' });
68
+ expect(onDialogCancel).toHaveBeenCalledTimes(1);
69
+ });
70
+
71
+ it('ignores Esc for mounted-but-closed layers so they do not swallow the open one', () => {
72
+ const onOpenCancel = vi.fn();
73
+ const onClosedCancel = vi.fn();
74
+ // 关闭态的 Dialog 后挂载:门控缺失时它会占据 esc 栈顶,吃掉打开层的 Esc
75
+ render(
76
+ <>
77
+ <Dialog open onCancel={onOpenCancel} keyboard title="open">
78
+ <div>body</div>
79
+ </Dialog>
80
+ <Dialog open={false} onCancel={onClosedCancel} keyboard title="closed">
81
+ <div>body</div>
82
+ </Dialog>
83
+ </>,
84
+ );
85
+
86
+ fireEvent.keyDown(document, { key: 'Escape' });
87
+ expect(onOpenCancel).toHaveBeenCalledTimes(1);
88
+ expect(onClosedCancel).not.toHaveBeenCalled();
89
+ });
90
+ });
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect } from 'react';
1
+ import { useEffect, useState } from 'react';
2
2
  import useEvent from './useEvent';
3
3
 
4
4
  export interface UseEscProps {
@@ -13,8 +13,14 @@ export interface UseEscProps {
13
13
  enabled?: boolean;
14
14
  }
15
15
 
16
+ // 层级栈:所有 useEsc 监听都挂在 document 上,同一 keydown 会触发全部 handler。
17
+ // 按启用顺序压栈,Esc 只响应最后启用(最上层)的那一个 —— 弹层内再开
18
+ // 筛选/下拉/抽屉时,第一次 Esc 只关最上层,再按才逐层关闭。
19
+ let escSeq = 0;
20
+ const escLayers: number[] = [];
21
+
16
22
  /**
17
- * React Hook for listening to ESC key press
23
+ * Hook for listening to ESC key press
18
24
  *
19
25
  * @example
20
26
  * ```tsx
@@ -29,24 +35,29 @@ export interface UseEscProps {
29
35
  export default function useEsc({ onEsc, enabled = true }: UseEscProps = {}) {
30
36
  const handleEsc = useEvent(onEsc || (() => {}));
31
37
 
32
- const handleKeyDown = useCallback(
33
- (event: KeyboardEvent) => {
34
- if (event.key === 'Escape') {
35
- event.preventDefault();
36
- handleEsc(event);
37
- }
38
- },
39
- [handleEsc],
40
- );
38
+ const [id] = useState(() => ++escSeq);
41
39
 
42
40
  useEffect(() => {
43
41
  if (!enabled) {
44
42
  return;
45
43
  }
46
44
 
45
+ escLayers.push(id);
46
+
47
+ const handleKeyDown = (event: KeyboardEvent) => {
48
+ if (event.key === 'Escape' && escLayers[escLayers.length - 1] === id) {
49
+ event.preventDefault();
50
+ handleEsc(event);
51
+ }
52
+ };
53
+
47
54
  document.addEventListener('keydown', handleKeyDown);
48
55
  return () => {
49
56
  document.removeEventListener('keydown', handleKeyDown);
57
+ const index = escLayers.lastIndexOf(id);
58
+ if (index !== -1) {
59
+ escLayers.splice(index, 1);
60
+ }
50
61
  };
51
- }, [enabled, handleKeyDown]);
62
+ }, [enabled, handleEsc, id]);
52
63
  }
package/src/version.ts CHANGED
@@ -4,7 +4,7 @@ declare global {
4
4
  }
5
5
  }
6
6
 
7
- export const version = "0.15.1";
7
+ export const version = "0.15.3";
8
8
 
9
9
  if (typeof window !== 'undefined') {
10
10
  window.__yee_c_version__ = version;