Repository: arco-design/arco-design (ts)

Issue:
对 trigger/tooltip/popover 增加一个 API：当检测到页面滚动的时候，关闭 popup
- [ ] I'm sure this does not appear in [the issue list of the repository](https://github.com/arco-design/arco-design/issues)

## Basic Info 

## What are the similar cases of this feature
N/A

## What problem does this feature solve?
- 当 tooltip/popover 在表单 cell 内（默认 getPopupContainer={() => document.body}），表单内横向滚动的时候，tooltip/popover 不随着横向滚动而滚动。

- 如果设置 getPopupContainer={(triggerNode) => triggerNode.parentNode} 的话，tooltip/popover 会随着表单横向滚动。但是，表单第一行触发的 popover 会被 overflow: hidden 截断。

- 所以，可能的最简单的方案是：当监听到滚动的时候，隐藏 trigger/tooltip/popover 的 popup。

- 因此建议：对 trigger/tooltip/popover 增加一个 API（boolean）：当检测到页面滚动的时候，关闭 popup。


<!-- generated by arco-issue. DO NOT REMOVE -->

Pull request:
feat: Trigger supports updateAtScroll property
<!--
  Thanks so much for your PR and contribution.

  Before submitting, please make sure to follow the Pull Request Guidelines: https://github.com/arco-design/arco-design/blob/main/CONTRIBUTING.md
-->

<!-- Put an `x` in "[ ]" to check a box) -->

## Types of changes

<!-- What types of changes does this PR introduce -->
<!-- Only support choose one type, if there are multiple types, you can add the `Type` column in the Changelog. -->

- [x] New feature
- [ ] Bug fix
- [ ] Enhancement
- [ ] Documentation change
- [ ] Coding style change
- [ ] Component style change
- [ ] Refactoring
- [ ] Test cases
- [ ] Continuous integration
- [ ] Typescript definition change
- [ ] Breaking change
- [ ] Others 

## Background and context

<!-- Explain what problem does the PR solve -->
<!-- Link to related open issues if applicable -->

## Solution

<!-- Describe how the problem is fixed in detail -->

## How is the change tested?

<!-- Unit tests should be added/updated for bug fixes and new features, if applicable -->
<!-- Please describe how you tested the change. E.g. Creating/updating unit tests or attaching a screenshot of how it works with your change -->

## Changelog

| Component | Changelog(CN) | Changelog(EN) | Related issues |
| --------- | ------------- | ------------- | -------------- |
|     Trigger      |   `Trigger` 组件支持通过 `updateAtScroll` 属性，设置在容器滚动时更新弹出框的位置。            |    The `Trigger` component supports the `updateAtScroll` property to update the position of the popover when the container is scrolled.           |     close #652           |

<!-- If there are multiple types, you can add the `Type` column in the Changelog, the value of the column is the same as `Types of changes` -->
## Checklist:

- [ ] Test suite passes (`npm run test`)
- [ ] Provide changelog for relevant changes (e.g. bug fixes and new features) if applicable.
- [ ] Changes are submitted to the appropriate branch (e.g. features should be submitted to `feature` branch and others should be submitted to `main` branch)

## Other information

<!-- Please describe what other information that should be taken care of. E.g. describe the impact if introduce a breaking change -->


Patch:
diff --git a/components/Trigger/README.en-US.md b/components/Trigger/README.en-US.md
index 483638c2..9d80fea2 100644
--- a/components/Trigger/README.en-US.md
+++ b/components/Trigger/README.en-US.md
@@ -49,6 +49,7 @@ Used to pop up a drop-down box by hovering, focusing, or clicking on a element.
 |autoFixPosition|Whether to automatically reposition when the popup's size changes.|`boolean`|`true`|-|
 |showArrow|Whether to display arrow node|`boolean`|`-`|-|
 |arrowProps|The html attributes of the arrow node|`HTMLAttributes<HTMLDivElement>`|`-`|-|
+|updateOnScroll|Whether to update the popover's position when the container is scrolled|`boolean`|`-`|2.32.0|
 
 ### When to fired `onVisibleChange`
 
@@ -73,6 +74,7 @@ Used to pop up a drop-down box by hovering, focusing, or clicking on a element.
   2. Click on the area outside the child node and the popup will hide the popup if you set `blurToHide=false`.
 
 
+
 ## Q&A
 
 1. When `getPopupContainer` is set, the popup layer is in the wrong position?
diff --git a/components/Trigger/README.zh-CN.md b/components/Trigger/README.zh-CN.md
index de6ced42..66e0cbcf 100644
--- a/components/Trigger/README.zh-CN.md
+++ b/components/Trigger/README.zh-CN.md
@@ -49,6 +49,7 @@
 |autoFixPosition|当内容发生变化导致内容区域尺寸发生变化，自动进行重新定位。|`boolean`|`true`|-|
 |showArrow|是否展示箭头元素|`boolean`|`-`|-|
 |arrowProps|箭头元素的所有 html 参数|`HTMLAttributes<HTMLDivElement>`|`-`|-|
+|updateOnScroll|是否在容器滚动时更新弹出框的位置|`boolean`|`-`|2.32.0|
 
 ### `onVisibleChange`的触发时机说明
 
@@ -78,9 +79,7 @@
 ## 常见问题
 
 1. 设置了 `getPopupContainer` 后，弹出层位置不对？
-
    组件默认 `autoFitPosition` 属性为 `true`，也就是说组件会根据弹出层的尺寸，在视口中的位置以及所挂载的父元素来自动调整弹出层的位置。
    例如设置 `position=top` ，但是上方空间不足，弹出层会被遮住，组件就会根据情况来判断是否将弹出层展示在触发元素的下方。
    如果设置了 `getPopupContainer`，弹出层的样式属性`left`最小为0，也就是最小距离父元素的左边界为0，避免超出父元素，导致被遮挡。
-   可以通过设置 `autoFitPosition=false` 来避免自动调整位置。
 
diff --git a/components/Trigger/__demo__/updateAtScroll.md b/components/Trigger/__demo__/updateAtScroll.md
new file mode 100644
index 00000000..fdbdecbb
--- /dev/null
+++ b/components/Trigger/__demo__/updateAtScroll.md
@@ -0,0 +1,53 @@
+---
+order: 8
+title:
+  zh-CN: 滚动容器
+  en-US: updateOnScroll
+---
+
+## zh-CN
+
+通过设置 `updateOnScroll` 监听容器的滚动。
+
+## en-US
+
+Listen for container scrolling by setting `updateOnScroll`.
+
+
+```js
+import { Trigger, Button, Input, Skeleton } from '@arco-design/web-react';
+
+function Popup () {
+  return  <Skeleton className="demo-trigger-popup" style={{width: 300}} />;
+}
+
+function Demo () {
+
+  return <div style={{ background: 'var(--color-fill-2)', padding: 40, height: 100, overflow: 'auto' }}>
+
+    <div style={{height: 200}}>
+      <Trigger
+        trigger="click"
+        position="bottom"
+        popup={() => <Popup/>}
+        updateOnScroll
+      >
+        <Button type="primary" style={{marginTop: 80}}>Button</Button>
+      </Trigger>
+    </div>
+  </div>
+}
+
+ReactDOM.render(<Demo />, CONTAINER);
+
+```
+
+```css:silent
+.demo-trigger-popup {
+  padding: 10px;
+  width: 300px;
+  text-align: center;
+  background-color: var(--color-bg-popup);
+  box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.15);
+}
+```
diff --git a/components/Trigger/index.tsx b/components/Trigger/index.tsx
index ae161806..0e8b8cd0 100644
--- a/components/Trigger/index.tsx
+++ b/components/Trigger/index.tsx
@@ -2,7 +2,7 @@ import React, { PureComponent, ReactElement, PropsWithChildren, CSSProperties }
 import { findDOMNode } from 'react-dom';
 import { CSSTransition } from 'react-transition-group';
 import ResizeObserverPolyfill from 'resize-observer-polyfill';
-import { on, off, contains } from '../_util/dom';
+import { on, off, contains, getScrollElements } from '../_util/dom';
 import { isFunction } from '../_util/is';
 import { Esc } from '../_util/keycode';
 import Portal from './portal';
@@ -147,9 +147,12 @@ class Trigger extends PureComponent<TriggerProps, TriggerState> {
   // 保存当前的mount container dom元素
   observerContainer = null;
 
+  // 保存当前节点到 popupContainer 间的所有滚动元素
+  scrollElements: HTMLElement[] = null;
+
   // container 触发 resize时执行
   resizeObserver = new ResizeObserverPolyfill(() => {
-    this.handleContainerResize();
+    this.handleUpdatePosition();
   });
 
   childrenDom = null;
@@ -211,6 +214,7 @@ class Trigger extends PureComponent<TriggerProps, TriggerState> {
       this.offClickOutside();
       this.offContainerResize();
       this.offWindowResize();
+      this.offScrollListeners();
       return;
     }
 
@@ -222,8 +226,11 @@ class Trigger extends PureComponent<TriggerProps, TriggerState> {
     }
     // popupVisible为true
     this.onContainerResize();
+    if (currentProps.updateOnScroll) {
+      this.onContainersScroll();
+    }
     if (!this.handleWindowResize) {
-      on(window, 'resize', this.handleContainerResize);
+      on(window, 'resize', this.handleUpdatePosition);
       this.handleWindowResize = true;
     }
 
@@ -244,13 +251,21 @@ class Trigger extends PureComponent<TriggerProps, TriggerState> {
     this.offClickOutside();
     this.clearTimer();
     this.offWindowResize();
+    this.offScrollListeners();
     this.offContainerResize();
     caf(this.rafId);
   }
 
+  offScrollListeners = () => {
+    (this.scrollElements || []).forEach((item) => {
+      off(item, 'scroll', this.handleUpdatePosition);
+    });
+    this.scrollElements = null;
+  };
+
   offWindowResize = () => {
     this.handleWindowResize = false;
-    off(window, 'resize', this.handleContainerResize);
+    off(window, 'resize', this.handleUpdatePosition);
   };
 
   offContainerResize = () => {
@@ -260,6 +275,17 @@ class Trigger extends PureComponent<TriggerProps, TriggerState> {
     }
   };
 
+  onContainersScroll = () => {
+    if (this.scrollElements) {
+      return;
+    }
+    this.scrollElements = getScrollElements(this.childrenDom, this.popupContainer?.parentNode);
+
+    this.scrollElements.forEach((item) => {
+      on(item, 'scroll', this.handleUpdatePosition);
+    });
+  };
+
   onContainerResize = () => {
     // containerParent 相当于是通过getPopupContainer传入的节点
     // 因为 this.popupContainer 会被挂载到getPopupContainer返回的节点上
@@ -273,7 +299,7 @@ class Trigger extends PureComponent<TriggerProps, TriggerState> {
   };
 
   // getPopupContainer 改变时候触发
-  handleContainerResize = throttleByRaf(() => {
+  handleUpdatePosition = throttleByRaf(() => {
     this.updatePopupPosition();
   });
 
@@ -698,7 +724,7 @@ class Trigger extends PureComponent<TriggerProps, TriggerState> {
   // 当 children 中的元素 disabled 时，不能正确触发 hover 等事件，所以当监测到对应
   // 组件有 disabled 时，给元素加一层 span，处理事件，模拟样式
   getChild = () => {
-    const { children } = this.getMergedProps();
+    const { children } = this.props;
 
     const element = children as ReactElement;
     const elementType = (element && typeof element !== 'string' && element.type) as any;
diff --git a/components/Trigger/interface.ts b/components/Trigger/interface.ts
index 374bc20d..82f825bf 100644
--- a/components/Trigger/interface.ts
+++ b/components/Trigger/interface.ts
@@ -218,6 +218,12 @@ export interface TriggerProps {
    * @en The html attributes of the arrow node
    */
   arrowProps?: HTMLAttributes<HTMLDivElement>;
+  /**
+   * @zh 是否在容器滚动时更新弹出框的位置
+   * @en Whether to update the popover's position when the container is scrolled
+   * @version 2.32.0
+   */
+  updateOnScroll?: boolean;
 }
 
 export type MouseLocationType = { clientX: number; clientY: number };
diff --git a/components/_util/dom.ts b/components/_util/dom.ts
index 747455c6..25d9c791 100644
--- a/components/_util/dom.ts
+++ b/components/_util/dom.ts
@@ -46,3 +46,28 @@ export const contains = function (root, ele) {
   }
   return false;
 };
+
+export const isScrollElement = (element: HTMLElement) => {
+  return element.scrollHeight > element.offsetHeight || element.scrollWidth > element.offsetWidth;
+};
+
+/**
+ * 从当前节点向上查找所有的滚动元素
+ * @param container 当前节点
+ * @param top 查找到 top 节点就终止，不再继续查找
+ * @returns
+ */
+export const getScrollElements = (
+  container: HTMLElement,
+  top: HTMLElement = document.documentElement
+): HTMLElement[] => {
+  const scrollElements: HTMLElement[] = [];
+  let element: HTMLElement | null = container;
+  while (element && element !== top) {
+    if (isScrollElement(element)) {
+      scrollElements.push(element);
+    }
+    element = element.parentElement;
+  }
+  return scrollElements;
+};


Test patch:
diff --git a/components/Trigger/__test__/__snapshots__/demo.test.ts.snap b/components/Trigger/__test__/__snapshots__/demo.test.ts.snap
index acb6ab7f..1ed7c7af 100644
--- a/components/Trigger/__test__/__snapshots__/demo.test.ts.snap
+++ b/components/Trigger/__test__/__snapshots__/demo.test.ts.snap
@@ -323,3 +323,23 @@ exports[`renders Trigger/demo/triggers.md correctly 1`] = `
   />
 </div>
 `;
+
+exports[`renders Trigger/demo/updateOnScroll.md correctly 1`] = `
+<div
+  style="background:var(--color-fill-2);padding:40px;height:100px;overflow:auto"
+>
+  <div
+    style="height:200px"
+  >
+    <button
+      class="arco-btn arco-btn-primary arco-btn-size-default arco-btn-shape-square"
+      style="margin-top:80px"
+      type="button"
+    >
+      <span>
+        Button
+      </span>
+    </button>
+  </div>
+</div>
+`;