Skip to content

Commit

Permalink
feat(sheet): add tooltip to FilterPanel (#2234)
Browse files Browse the repository at this point in the history
* feat(sheet): add tooltip to FilterPanel

* chore(sheet): remove not needed string

* refactor: use ctor instead of hook

* refactor: resizeObserverCtor

* refactor: lazy init

* feat(sheet): use container instead of viewport

* style: fix eslint issue
  • Loading branch information
siam-ese committed May 16, 2024
1 parent c59b5a5 commit 12d4aef
Show file tree
Hide file tree
Showing 8 changed files with 162 additions and 85 deletions.
27 changes: 21 additions & 6 deletions packages/design/src/components/tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@

import type { TooltipRef } from 'rc-tooltip';
import RcTooltip from 'rc-tooltip';
import type { Ref } from 'react';
import React, { forwardRef, useContext } from 'react';
import React, { forwardRef, useContext, useImperativeHandle, useRef } from 'react';

import { ConfigContext } from '../config-provider/ConfigProvider';
import styles from './index.module.less';
import { placements } from './placements';
import { useIsEllipsis } from './hooks';

export interface ITooltipProps {
visible?: boolean;
Expand All @@ -31,21 +31,36 @@ export interface ITooltipProps {
title: (() => React.ReactNode) | React.ReactNode;

children: React.ReactElement;
/* Tooltip only show if text is ellipsis */
showIfEllipsis?: boolean;

onVisibleChange?: (visible: boolean) => void;

style?: React.CSSProperties;
}

export const Tooltip = forwardRef((props: ITooltipProps, ref: Ref<TooltipRef>) => {
const { children, visible, placement = 'top', title, onVisibleChange, style } = props;
type NullableTooltipRef = TooltipRef | null;

export const Tooltip = forwardRef<NullableTooltipRef, ITooltipProps>((props, ref) => {
const {
children,
visible,
placement = 'top',
title,
onVisibleChange,
style,
showIfEllipsis = false,
} = props;

const { mountContainer } = useContext(ConfigContext);
const tooltipRef = useRef<NullableTooltipRef>(null);
useImperativeHandle<NullableTooltipRef, NullableTooltipRef>(ref, () => tooltipRef.current);

const isEllipsis = useIsEllipsis(showIfEllipsis ? tooltipRef.current?.nativeElement : null);
return mountContainer && (
<RcTooltip
visible={visible}
ref={ref}
visible={(showIfEllipsis && !isEllipsis) ? false : visible}
ref={tooltipRef}
prefixCls={styles.tooltip}
getTooltipContainer={() => mountContainer}
overlay={<div className={styles.tooltipContent}>{typeof title === 'function' ? title() : title}</div>}
Expand Down
64 changes: 64 additions & 0 deletions packages/design/src/components/tooltip/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Copyright 2023-present DreamNum Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http:https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { useEffect, useState } from 'react';
import canUseDom from 'rc-util/lib/Dom/canUseDom';

/**
* All elements are observed by a single ResizeObserver is got greater performance than each element observed by separate ResizeObserver
* See issue https://github.com/WICG/resize-observer/issues/59#issuecomment-408098151
*/
const _resizeObserverCallbacks: Set<ResizeObserverCallback> = new Set();
let _resizeObserver: ResizeObserver;

export function resizeObserverCtor(callback: ResizeObserverCallback) {
if (!_resizeObserver) {
_resizeObserver = new ResizeObserver((...args) => {
_resizeObserverCallbacks.forEach((callback) => callback(...args));
});
}
return {
observe(target: Element, options?: ResizeObserverOptions | undefined) {
_resizeObserverCallbacks.add(callback);
_resizeObserver.observe(target, options);
},
unobserve(target: Element) {
_resizeObserverCallbacks.delete(callback);
_resizeObserver.unobserve(target);
},
};
}

export function useIsEllipsis(element: HTMLElement | null | undefined) {
const [isEllipsis, setIsEllipsis] = useState(false);

useEffect(() => {
if (!canUseDom() || !element) {
return;
}

const resizeObserver = resizeObserverCtor(() => {
element && setIsEllipsis(element.scrollWidth > element.offsetWidth);
});
setIsEllipsis(element.scrollWidth > element.offsetWidth);
resizeObserver.observe(element);
return () => {
resizeObserver.unobserve(element);
};
}, [element]);

return isEllipsis;
}
1 change: 1 addition & 0 deletions packages/design/src/components/tooltip/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
*/

export { type ITooltipProps, Tooltip } from './Tooltip';
export { resizeObserverCtor } from './hooks';
2 changes: 1 addition & 1 deletion packages/design/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export { type ISelectProps, Select } from './components/select';
export { type ISelectListProps, SelectList } from './components/select-list';
export { type ISegmentedProps, Segmented } from './components/segmented';
export { type ISliderProps, Slider } from './components/slider';
export { type ITooltipProps, Tooltip } from './components/tooltip';
export { type ITooltipProps, Tooltip, resizeObserverCtor } from './components/tooltip';
export { type ITreeNodeProps, type ITreeProps, Tree, TreeSelectionMode } from './components/tree';
export { enUS, zhCN, ruRU } from './locale';
export { type ILocale } from './locale/interface';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ import { useDependency } from '@wendellhu/redi/react-bindings';
import { LocaleService } from '@univerjs/core';
import { useObservable } from '@univerjs/ui';
import List from 'rc-virtual-list';
import { Button, Checkbox, Input } from '@univerjs/design';

import { Button, Checkbox, Input, Tooltip } from '@univerjs/design';
import type { ByValuesModel, IFilterByValueItem } from '../../services/sheets-filter-panel.service';
import { statisticFilterByValueItems } from '../../models/utils';
import styles from './index.module.less';
Expand Down Expand Up @@ -78,7 +77,9 @@ export function FilterByValue(props: { model: ByValuesModel }) {
<div className={styles.sheetsFilterPanelValuesItem}>
<div className={styles.sheetsFilterPanelValuesItemInner}>
<Checkbox checked={item.checked} onChange={() => onFilterCheckToggled(item, !item.checked)}></Checkbox>
<span className={styles.sheetsFilterPanelValuesItemText}>{item.value}</span>
<Tooltip showIfEllipsis placement="top" title={item.value}>
<span className={styles.sheetsFilterPanelValuesItemText}>{item.value}</span>
</Tooltip>
<span className={styles.sheetsFilterPanelValuesItemCount}>{`(${item.count})`}</span>
<Button
className={styles.sheetsFilterPanelValuesItemExcludeButton}
Expand Down
121 changes: 53 additions & 68 deletions packages/ui/src/components/hooks/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,94 +14,79 @@
* limitations under the License.
*/

import { useEffect } from 'react';
import { BehaviorSubject } from 'rxjs';
import { useEffect, useRef } from 'react';
import canUseDom from 'rc-util/lib/Dom/canUseDom';

import type { Nullable } from '@univerjs/core';
import { resizeObserverCtor } from '@univerjs/design';
import { useEvent } from './event';
/**
* These hooks are used for browser layout
* Prefer to client-side
*/

/**
* To detect whether the element is displayed over the viewport
* Allow the element to scroll when its height over the container height
* @param element
* @returns $value To notice you detected result
* Container means the window view that the element displays in.
* Recommend pass the sheet mountContainer as container
* @param container
*/
function detectElementOverViewport(element: HTMLElement) {
const state$ = new BehaviorSubject<{
x: boolean;
y: boolean;
xe: boolean;
ye: boolean;
}>({
/** Element displayed on x-axis is not fully show */
x: false,
/** Element displayed on y-axis is not fully show */
y: false,
/** Element border is equal to viewport x edge */
xe: false,
/** Element border is equal to viewport y edge */
ye: false,
export function useScrollYOverContainer(element: Nullable<HTMLElement>, container: Nullable<HTMLElement>) {
const initialRectRef = useRef({
width: 0,
height: 0,
});
const updater = useEvent(() => {
if (!element || !container) {
return;
}

function update() {
const rect = element.getBoundingClientRect();
const { innerHeight, innerWidth } = window;

const overX = rect.x >= 0;
const overY = rect.y >= 0;

state$.next({
x: overX && rect.x + rect.width > innerWidth,
xe: overX && rect.x + rect.width === innerWidth,
y: overY && rect.y + rect.height > innerHeight,
ye: overY && rect.y + rect.height === innerHeight,
});
}
const elStyle = element.style;
const elRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (elRect.y < 0 && elRect.y + elRect.height <= 0) {
/* The element is hidden in viewport */
return;
}
if (Math.abs(elRect.y) < Math.abs(containerRect.y)) {
/* The position of element is higher than container */
elStyle.overflowY = '';
elStyle.maxHeight = '';
return;
}

const observer = new ResizeObserver(update);
observer.observe(element);
window.addEventListener('resize', update);
const relativeY = elRect.y - containerRect.y;

update();
const initialHeight = initialRectRef.current?.height || 0;

return {
value$: state$.asObservable(),
dispose() {
observer.disconnect();
window.removeEventListener('resize', update);
state$.complete();
},
};
}
if (containerRect.height >= relativeY + initialHeight) {
elStyle.overflowY = '';
elStyle.maxHeight = '';
} else {
elStyle.overflowY = 'scroll';
elStyle.maxHeight = `${containerRect.height - relativeY}px`;
}
});

/** Allow the element to scroll when its height over the viewport height */
export function useScrollOnOverViewport(element: Nullable<HTMLElement>, disabled: boolean = false) {
useEffect(() => {
if (canUseDom() || !element || disabled) {
if (!canUseDom() || !element || !container) {
return;
}
const rect = element.getBoundingClientRect();
initialRectRef.current = {
width: rect.width,
height: rect.height,
};

const detector = detectElementOverViewport(element);
detector.value$.subscribe(({ y, ye }) => {
const elStyle = element.style;
const rect = element.getBoundingClientRect();
// When element height over viewport sets height to fit in viewport
if (y) {
elStyle.overflowY = 'scroll';
elStyle.maxHeight = `${window.innerHeight - rect.y}px`;
} else if (!ye) {
/**
* If element height is equal to viewport, it may be because of my previous adjustment
* On height is less than viewport then set to auto
*/
elStyle.overflowY = '';
elStyle.maxHeight = '';
}
});
updater();

return detector.dispose;
}, [element, disabled]);
const resizeObserver = resizeObserverCtor(updater);
resizeObserver.observe(element);
window.addEventListener('resize', updater);
return () => {
resizeObserver.unobserve(element);
window.removeEventListener('resize', updater);
};
}, [element, container]);
}
20 changes: 13 additions & 7 deletions packages/ui/src/components/menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@
*/

import { isRealNum } from '@univerjs/core';
import type {
MenuRef as DesignMenuRef } from '@univerjs/design';

import {
Menu as DesignMenu,
MenuItem as DesignMenuItem,
Expand All @@ -26,7 +25,7 @@ import {
import { CheckMarkSingle, MoreSingle } from '@univerjs/icons';
import { useDependency } from '@wendellhu/redi/react-bindings';
import clsx from 'clsx';
import React, { useRef, useState } from 'react';
import React, { useState } from 'react';
import { isObservable } from 'rxjs';

import type {
Expand All @@ -41,7 +40,8 @@ import { MenuGroup, MenuItemType } from '../../services/menu/menu';
import { IMenuService } from '../../services/menu/menu.service';
import { CustomLabel } from '../custom-label/CustomLabel';
import { useObservable } from '../hooks/observable';
import { useScrollOnOverViewport } from '../hooks/layout.ts';
import { useScrollYOverContainer } from '../hooks/layout.ts';
import { ILayoutService } from '../../services/layout/layout.service';
import styles from './index.module.less';

// TODO: @jikkai disabled and hidden are not working
Expand Down Expand Up @@ -172,10 +172,16 @@ function MenuOptionsWrapper(props: IBaseMenuProps) {

export const Menu = (props: IBaseMenuProps) => {
const { overViewport, ...restProps } = props;
const menuRef = useRef<DesignMenuRef>(null);
useScrollOnOverViewport(menuRef.current?.list, overViewport !== 'scroll');
const [menuEl, setMenuEl] = useState<HTMLDListElement>();
const layoutService = useDependency(ILayoutService);

useScrollYOverContainer(overViewport === 'scroll' ? menuEl : null, layoutService.rootContainerElement);

return (
<DesignMenu ref={menuRef} selectable={false}>
<DesignMenu
ref={(ref) => ref?.list && setMenuEl(ref.list)}
selectable={false}
>
<MenuOptionsWrapper {...restProps} />
<MenuWrapper {...restProps} />
</DesignMenu>
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/services/layout/layout.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const givingBackFocusElements = [
export interface ILayoutService {
readonly isFocused: boolean;

get rootContainerElement(): Nullable<HTMLElement>;
/** Re-focus the currently focused Univer business instance. */
focus(): void;

Expand Down Expand Up @@ -82,6 +83,10 @@ export class DesktopLayoutService extends Disposable implements ILayoutService {
this._initEditorStatus();
}

get rootContainerElement() {
return this._rootContainerElement;
}

focus(): void {
const currentFocused = this._univerInstanceService.getFocusedUnit();
if (!currentFocused) {
Expand Down

0 comments on commit 12d4aef

Please sign in to comment.