Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(sheet): add tooltip to FilterPanel #2234

Merged
merged 8 commits into from
May 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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://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
Loading